fix(auth): add OIDC sub claim validation and complete env example

- Add null/blank validation for OIDC sub claim in CustomOidcUserService
- Throw OAuth2AuthenticationException when sub is missing or blank
- Complete .env.release.example with all required OIDC environment variables
- Add test cases for sub validation and providerLogin fallback scenarios
- All 5 tests passing
This commit is contained in:
dongmucat 2026-04-28 13:49:26 +08:00
parent bb9f8915e2
commit 4f44ea3976
3 changed files with 49 additions and 2 deletions

View file

@ -63,8 +63,17 @@ OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: OIDC providers are configured with Spring Security client registration variables.
# See docs/09-deployment.md for the full OIDC environment variable set and Compose override notes.
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID=
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET=
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_PROVIDER=oidc
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_AUTHORIZATION_GRANT_TYPE=authorization_code
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_REDIRECT_URI={baseUrl}/login/oauth2/code/{registrationId}
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=

View file

@ -11,6 +11,7 @@ import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
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.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
@ -66,6 +67,10 @@ public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest,
static OAuthClaims toOAuthClaims(OidcUserRequest request, OidcUser oidcUser) {
Map<String, Object> claims = new HashMap<>(oidcUser.getClaims());
String subject = asString(claims.get("sub"));
if (subject == null || subject.isBlank()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("missing_sub", "OIDC sub claim is required", null));
}
String email = asString(claims.get("email"));
boolean emailVerified = Boolean.TRUE.equals(claims.get("email_verified"));
String providerLogin = firstPresent(

View file

@ -14,6 +14,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
@ -21,6 +22,7 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@ -92,6 +94,37 @@ class CustomOidcUserServiceTest {
assertThat(claims.providerLogin()).isEqualTo("Fallback Name");
}
@Test
void toOAuthClaims_throwsWhenSubIsMissing() {
OidcUser user = mock(OidcUser.class);
when(user.getClaims()).thenReturn(Map.of("email", "no-sub@example.com"));
assertThatThrownBy(() -> CustomOidcUserService.toOAuthClaims(oidcRequest(), user))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("sub");
}
@Test
void toOAuthClaims_throwsWhenSubIsBlank() {
OidcUser user = mock(OidcUser.class);
when(user.getClaims()).thenReturn(Map.of(IdTokenClaimNames.SUB, " "));
assertThatThrownBy(() -> CustomOidcUserService.toOAuthClaims(oidcRequest(), user))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("sub");
}
@Test
void toOAuthClaims_fallsBackToSubWhenAllOtherFieldsMissing() {
OAuthClaims claims = CustomOidcUserService.toOAuthClaims(
oidcRequest(),
oidcUser(Map.of(IdTokenClaimNames.SUB, "only-sub"))
);
assertThat(claims.subject()).isEqualTo("only-sub");
assertThat(claims.providerLogin()).isEqualTo("only-sub");
assertThat(claims.email()).isNull();
assertThat(claims.emailVerified()).isFalse();
}
private static OidcUserRequest oidcRequest() {
Instant issuedAt = Instant.parse("2026-04-24T00:00:00Z");
OidcIdToken idToken = new OidcIdToken(