diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index dbd3bab8..a47c1e29 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -56,7 +56,7 @@ spring: scope: - read:user - user:email - redirect-uri: ${SKILLHUB_PUBLIC_BASE_URL:http://localhost:8080}/login/oauth2/code/github + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: GitHub authorization-grant-type: authorization_code gitlab: @@ -66,7 +66,7 @@ spring: - read_user - email authorization-grant-type: authorization_code - redirect-uri: ${SKILLHUB_PUBLIC_BASE_URL:http://localhost:8080}/login/oauth2/code/gitlab + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} provider: github: diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java index 953c5cc0..d6c840a2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.oauth; +import com.fasterxml.jackson.annotation.JsonProperty; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -26,8 +27,8 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor { private final RestClient restClient; - public GitLabClaimsExtractor() { - this.restClient = RestClient.builder() + public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .build(); } @@ -45,9 +46,7 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor { // GitLab returns email directly in user info String email = (String) attrs.get("email"); - // GitLab provides email_verified field in the user info response - Boolean emailVerifiedObj = (Boolean) attrs.get("email_verified"); - boolean emailVerified = emailVerifiedObj != null && emailVerifiedObj; + boolean emailVerified = isConfirmed(attrs.get("confirmed_at")); log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified); @@ -129,10 +128,15 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor { } /** - * Represents a GitLab email object from the /user/emails API. - * - * @param email the email address - * @param confirmed whether the email has been confirmed + * GitLab marks a confirmed email by populating confirmed_at. */ - private record GitLabEmail(String email, boolean confirmed) {} + private record GitLabEmail(String email, @JsonProperty("confirmed_at") String confirmedAt) { + boolean confirmed() { + return confirmedAt != null && !confirmedAt.isBlank(); + } + } + + private boolean isConfirmed(Object confirmedAt) { + return confirmedAt instanceof String value && !value.isBlank(); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java new file mode 100644 index 00000000..643be583 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.auth.oauth; + +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.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +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.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class GitLabClaimsExtractorTest { + + @Test + void extract_marksProfileEmailVerifiedWhenConfirmedAtPresent() { + RestClient.Builder restClientBuilder = RestClient.builder(); + GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); + + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "id", 42, + "username", "alice", + "email", "alice@gitlab.example", + "confirmed_at", "2026-04-16T08:00:00Z" + ), + "username" + ) + ); + + assertThat(claims.email()).isEqualTo("alice@gitlab.example"); + assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.providerLogin()).isEqualTo("alice"); + } + + @Test + void extract_loadsConfirmedEmailFromEmailListWhenProfileEmailIsUnconfirmed() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://gitlab.example.com/api/v4/user/emails")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) + .andRespond(withSuccess( + """ + [ + {"email":"alice@gitlab.example","confirmed_at":"2026-04-16T08:00:00Z"}, + {"email":"alice+pending@gitlab.example","confirmed_at":null} + ] + """, + MediaType.APPLICATION_JSON + )); + GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); + + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "id", 42, + "username", "alice", + "email", "alice+pending@gitlab.example" + ), + "username" + ) + ); + + assertThat(claims.email()).isEqualTo("alice@gitlab.example"); + assertThat(claims.emailVerified()).isTrue(); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab") + .clientId("client-id") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("read_user", "email") + .authorizationUri("https://gitlab.example.com/oauth/authorize") + .tokenUri("https://gitlab.example.com/oauth/token") + .userInfoUri("https://gitlab.example.com/api/v4/user") + .userNameAttributeName("username") + .clientName("GitLab") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +}