From 9f9c14c1bbf6bf3ce906c8bf17636ecec63c180a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 4 Dec 2025 16:11:56 +0100 Subject: [PATCH] Fix login --- .../imgfloat/app/config/SecurityConfig.java | 7 ++ ...zationCodeGrantRequestEntityConverter.java | 56 +++++++++ .../TwitchClientRegistrationConfig.java | 84 ++++++++++++++ .../app/config/TwitchOAuth2UserService.java | 106 ++++++++++++++++++ src/main/resources/application.yml | 3 +- .../app/TwitchEnvironmentValidationTest.java | 24 ++++ ...onCodeGrantRequestEntityConverterTest.java | 57 ++++++++++ .../config/TwitchOAuth2UserServiceTest.java | 76 +++++++++++++ 8 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java create mode 100644 src/main/java/com/imgfloat/app/config/TwitchClientRegistrationConfig.java create mode 100644 src/main/java/com/imgfloat/app/config/TwitchOAuth2UserService.java create mode 100644 src/test/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java create mode 100644 src/test/java/com/imgfloat/app/config/TwitchOAuth2UserServiceTest.java diff --git a/src/main/java/com/imgfloat/app/config/SecurityConfig.java b/src/main/java/com/imgfloat/app/config/SecurityConfig.java index 1b90631..6c9d910 100644 --- a/src/main/java/com/imgfloat/app/config/SecurityConfig.java +++ b/src/main/java/com/imgfloat/app/config/SecurityConfig.java @@ -26,6 +26,7 @@ public class SecurityConfig { ) .oauth2Login(oauth -> oauth .tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) + .userInfoEndpoint(user -> user.userService(twitchOAuth2UserService())) ) .logout(logout -> logout.logoutSuccessUrl("/").permitAll()) .csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); @@ -35,9 +36,15 @@ public class SecurityConfig { @Bean OAuth2AccessTokenResponseClient twitchAccessTokenResponseClient() { DefaultAuthorizationCodeTokenResponseClient delegate = new DefaultAuthorizationCodeTokenResponseClient(); + delegate.setRequestEntityConverter(new TwitchAuthorizationCodeGrantRequestEntityConverter()); RestTemplate restTemplate = OAuth2RestTemplateFactory.create(); restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler()); delegate.setRestOperations(restTemplate); return delegate; } + + @Bean + TwitchOAuth2UserService twitchOAuth2UserService() { + return new TwitchOAuth2UserService(); + } } diff --git a/src/main/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java b/src/main/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java new file mode 100644 index 0000000..cc3e53a --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java @@ -0,0 +1,56 @@ +package com.imgfloat.app.config; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Ensures Twitch token requests always include {@code client_id} and {@code client_secret} in the + * request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if + * those parameters are absent. + */ +final class TwitchAuthorizationCodeGrantRequestEntityConverter implements + Converter> { + + private final Converter> delegate = + new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest request) { + RequestEntity entity = delegate.convert(request); + if (entity == null || !(entity.getBody() instanceof MultiValueMap existingBody)) { + return entity; + } + + ClientRegistration registration = request.getClientRegistration(); + + MultiValueMap body = cloneBody(existingBody); + body.set(OAuth2ParameterNames.CLIENT_ID, registration.getClientId()); + if (registration.getClientSecret() != null) { + body.set(OAuth2ParameterNames.CLIENT_SECRET, registration.getClientSecret()); + } + + return new RequestEntity<>( + body, + entity.getHeaders(), + entity.getMethod() == null ? HttpMethod.POST : entity.getMethod(), + entity.getUrl() == null ? URI.create(registration.getProviderDetails().getTokenUri()) : entity.getUrl()); + } + + private MultiValueMap cloneBody(MultiValueMap existingBody) { + MultiValueMap copy = new LinkedMultiValueMap<>(); + existingBody.forEach((key, value) -> + copy.put(String.valueOf(key), new ArrayList<>((List) value))); + return copy; + } +} diff --git a/src/main/java/com/imgfloat/app/config/TwitchClientRegistrationConfig.java b/src/main/java/com/imgfloat/app/config/TwitchClientRegistrationConfig.java new file mode 100644 index 0000000..373662e --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/TwitchClientRegistrationConfig.java @@ -0,0 +1,84 @@ +package com.imgfloat.app.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; + +/** + * Sanitizes Twitch client registration values (especially whitespace or quoted secrets) before + * wiring them into Spring Security. + */ +@Configuration +@EnableConfigurationProperties(OAuth2ClientProperties.class) +class TwitchClientRegistrationConfig { + private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class); + + @Bean + @Primary + ClientRegistrationRepository twitchClientRegistrationRepository(OAuth2ClientProperties properties) { + List registrations = new ArrayList<>(); + for (Map.Entry entry : properties.getRegistration().entrySet()) { + String registrationId = entry.getKey(); + OAuth2ClientProperties.Registration registration = entry.getValue(); + String providerId = registration.getProvider() != null ? registration.getProvider() : registrationId; + OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId); + if (provider == null) { + throw new IllegalStateException( + "Missing OAuth2 provider configuration for registration '" + registrationId + "'."); + } + if (!"twitch".equals(registrationId)) { + LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId); + continue; + } + registrations.add(buildTwitchRegistration(registrationId, registration, provider)); + } + return new InMemoryClientRegistrationRepository(registrations); + } + + private ClientRegistration buildTwitchRegistration( + String registrationId, + OAuth2ClientProperties.Registration registration, + OAuth2ClientProperties.Provider provider) { + String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID"); + String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET"); + return ClientRegistration.withRegistrationId(registrationId) + .clientName(registration.getClientName()) + .clientId(clientId) + .clientSecret(clientSecret) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType())) + .redirectUri(registration.getRedirectUri()) + .scope(registration.getScope()) + .authorizationUri(provider.getAuthorizationUri()) + .tokenUri(provider.getTokenUri()) + .userInfoUri(provider.getUserInfoUri()) + .userNameAttributeName(provider.getUserNameAttribute()) + .build(); + } + + private String sanitize(String value, String name) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + String unquoted = trimmed.substring(1, trimmed.length() - 1).trim(); + LOG.info("Sanitizing {} by stripping surrounding quotes.", name); + return unquoted; + } + return trimmed; + } +} diff --git a/src/main/java/com/imgfloat/app/config/TwitchOAuth2UserService.java b/src/main/java/com/imgfloat/app/config/TwitchOAuth2UserService.java new file mode 100644 index 0000000..d94da1a --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/TwitchOAuth2UserService.java @@ -0,0 +1,106 @@ +package com.imgfloat.app.config; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.client.RestTemplate; + +/** + * Adds the Twitch required "Client-ID" header to user info requests while preserving the default + * Spring behavior for mapping the response into an {@link OAuth2User}. + */ +class TwitchOAuth2UserService implements OAuth2UserService { + + private final Function restTemplateFactory; + + TwitchOAuth2UserService() { + this(TwitchOAuth2UserService::createRestTemplate); + } + + TwitchOAuth2UserService(Function restTemplateFactory) { + this.restTemplateFactory = restTemplateFactory; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + delegate.setRestOperations(restTemplateFactory.apply(userRequest)); + OAuth2User delegateUser = delegate.loadUser(twitchUserRequest(userRequest)); + Map twitchUser = unwrapUserAttributes(delegateUser.getAttributes()); + return new DefaultOAuth2User(delegateUser.getAuthorities(), twitchUser, "login"); + } + + private OAuth2UserRequest twitchUserRequest(OAuth2UserRequest userRequest) { + return new OAuth2UserRequest( + twitchUserRegistration(userRequest), + userRequest.getAccessToken(), + userRequest.getAdditionalParameters()); + } + + private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) { + ClientRegistration registration = userRequest.getClientRegistration(); + return ClientRegistration.withClientRegistration(registration) + // The Twitch response nests user details under a "data" array, so accept that + // shape for the initial parsing step. + .userNameAttributeName("data") + .build(); + } + + @SuppressWarnings("unchecked") + private Map unwrapUserAttributes(Map responseAttributes) { + Object data = responseAttributes.get("data"); + if (!(data instanceof List list) || list.isEmpty() || !(list.get(0) instanceof Map first)) { + throw invalidUserInfo("Missing Twitch user array in user info response"); + } + + Map twitchUser = new HashMap<>(); + first.forEach((key, value) -> twitchUser.put(String.valueOf(key), value)); + + Object login = twitchUser.get("login"); + if (!(login instanceof String loginValue) || loginValue.isBlank()) { + throw invalidUserInfo("Missing Twitch login in user info response"); + } + + return twitchUser; + } + + private OAuth2AuthenticationException invalidUserInfo(String message) { + return new OAuth2AuthenticationException(new OAuth2Error("invalid_user_info_response", message, null)); + } + + static RestTemplate createRestTemplate(OAuth2UserRequest userRequest) { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setRequestFactory(createRequestFactory(restTemplate.getRequestFactory())); + List interceptors = new ArrayList<>(restTemplate.getInterceptors()); + interceptors.add((request, body, execution) -> { + request.getHeaders().add("Client-ID", userRequest.getClientRegistration().getClientId()); + return execution.execute(request, body); + }); + restTemplate.setInterceptors(interceptors); + restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler()); + return restTemplate; + } + + private static ClientHttpRequestFactory createRequestFactory(ClientHttpRequestFactory existing) { + if (existing instanceof SimpleClientHttpRequestFactory simple) { + simple.setConnectTimeout(30_000); + simple.setReadTimeout(30_000); + return simple; + } + return existing; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f698cca..6935066 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,6 +20,7 @@ spring: twitch: client-id: ${TWITCH_CLIENT_ID} client-secret: ${TWITCH_CLIENT_SECRET} + client-authentication-method: client_secret_post redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}" authorization-grant-type: authorization_code scope: ["user:read:email"] @@ -28,7 +29,7 @@ spring: authorization-uri: https://id.twitch.tv/oauth2/authorize token-uri: https://id.twitch.tv/oauth2/token user-info-uri: https://api.twitch.tv/helix/users - user-name-attribute: preferred_username + user-name-attribute: login management: endpoints: diff --git a/src/test/java/com/imgfloat/app/TwitchEnvironmentValidationTest.java b/src/test/java/com/imgfloat/app/TwitchEnvironmentValidationTest.java index dcd3ebd..f74f7e0 100644 --- a/src/test/java/com/imgfloat/app/TwitchEnvironmentValidationTest.java +++ b/src/test/java/com/imgfloat/app/TwitchEnvironmentValidationTest.java @@ -37,4 +37,28 @@ class TwitchEnvironmentValidationTest { } } } + + @Test + void stripsQuotesAndWhitespaceFromCredentials() { + ConfigurableApplicationContext context = null; + try { + context = new SpringApplicationBuilder(ImgfloatApplication.class) + .properties( + "server.port=0", + "TWITCH_CLIENT_ID=\" quoted-id \"", + "TWITCH_CLIENT_SECRET=' quoted-secret '" + ) + .run(); + + var clientRegistrationRepository = context.getBean(org.springframework.security.oauth2.client.registration.ClientRegistrationRepository.class); + var twitch = clientRegistrationRepository.findByRegistrationId("twitch"); + + org.assertj.core.api.Assertions.assertThat(twitch.getClientId()).isEqualTo("quoted-id"); + org.assertj.core.api.Assertions.assertThat(twitch.getClientSecret()).isEqualTo("quoted-secret"); + } finally { + if (context != null) { + context.close(); + } + } + } } diff --git a/src/test/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java b/src/test/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java new file mode 100644 index 0000000..0ac1645 --- /dev/null +++ b/src/test/java/com/imgfloat/app/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java @@ -0,0 +1,57 @@ +package com.imgfloat.app.config; + +import org.junit.jupiter.api.Test; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; + +class TwitchAuthorizationCodeGrantRequestEntityConverterTest { + + @Test + void addsClientIdAndSecretToTokenRequestBody() { + ClientRegistration registration = ClientRegistration.withRegistrationId("twitch") + .clientId("twitch-id") + .clientSecret("twitch-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("https://example.com/redirect") + .scope("user:read:email") + .authorizationUri("https://id.twitch.tv/oauth2/authorize") + .tokenUri("https://id.twitch.tv/oauth2/token") + .userInfoUri("https://api.twitch.tv/helix/users") + .userNameAttributeName("preferred_username") + .build(); + + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .clientId(registration.getClientId()) + .redirectUri(registration.getRedirectUri()) + .state("state") + .build(); + + OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code") + .redirectUri(registration.getRedirectUri()) + .state("state") + .build(); + + OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse); + OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(registration, exchange); + + var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter(); + RequestEntity requestEntity = converter.convert(grantRequest); + + MultiValueMap body = (MultiValueMap) requestEntity.getBody(); + + assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id"); + assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret"); + } +} diff --git a/src/test/java/com/imgfloat/app/config/TwitchOAuth2UserServiceTest.java b/src/test/java/com/imgfloat/app/config/TwitchOAuth2UserServiceTest.java new file mode 100644 index 0000000..34e8936 --- /dev/null +++ b/src/test/java/com/imgfloat/app/config/TwitchOAuth2UserServiceTest.java @@ -0,0 +1,76 @@ +package com.imgfloat.app.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +class TwitchOAuth2UserServiceTest { + + @Test + void unwrapsTwitchUserAndAddsClientIdHeaderToUserInfoRequest() { + ClientRegistration registration = twitchRegistrationBuilder() + .clientId("client-123") + .clientSecret("secret") + .build(); + + OAuth2UserRequest userRequest = userRequest(registration); + RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + + TwitchOAuth2UserService service = new TwitchOAuth2UserService(ignored -> restTemplate); + + server.expect(requestTo("https://api.twitch.tv/helix/users")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("Client-ID", "client-123")) + .andRespond(withSuccess( + "{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}", + MediaType.APPLICATION_JSON)); + + OAuth2User user = service.loadUser(userRequest); + + assertThat(user.getName()).isEqualTo("demo"); + assertThat(user.getAttributes()) + .containsEntry("id", "42") + .containsEntry("display_name", "Demo"); + server.verify(); + } + + private OAuth2UserRequest userRequest(ClientRegistration registration) { + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token", + Instant.now(), + Instant.now().plusSeconds(60), + Set.of("user:read:email")); + return new OAuth2UserRequest(registration, accessToken); + } + + private ClientRegistration.Builder twitchRegistrationBuilder() { + return ClientRegistration.withRegistrationId("twitch") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientName("Twitch") + .redirectUri("https://example.com/login/oauth2/code/twitch") + .authorizationUri("https://id.twitch.tv/oauth2/authorize") + .tokenUri("https://id.twitch.tv/oauth2/token") + .userInfoUri("https://api.twitch.tv/helix/users") + .userNameAttributeName("login"); + } +}