UsernameClaimOidcUserService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.security;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.rest.springboot.model.auth.UserAuthRole;
import org.entrystore.rest.springboot.service.OidcAuthService;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;

import java.util.LinkedHashSet;
import java.util.Set;

/**
 * {@link OidcUserService} that names the principal after the per-provider configured claim
 * ({@code entrystore.auth.oidc.provider.{id}.username-claim}, default {@code email}), resolved
 * from the merged ID-token and userinfo claims. The shared SSO success flow
 * ({@code AbstractSsoLoginSuccessHandler}) and the per-request principal mapping
 * ({@code SetUserURIAfterAuthenticationFilter}) both derive the EntryStore username from
 * {@code authentication.getName()}, so remapping the name here is the single point that keeps
 * the two consistent.
 * <p>
 * A login whose tokens carry no usable value for the configured claim is rejected with an
 * {@link OAuth2AuthenticationException} (routed to the OIDC failure handler) instead of falling
 * back to another claim — a silent fallback to e.g. {@code sub} could match or auto-provision an
 * unintended EntryStore user.
 */
@Slf4j
@RequiredArgsConstructor
public class UsernameClaimOidcUserService extends OidcUserService {

	static final String ERROR_CODE_INVALID_USERNAME_CLAIM = "invalid_username_claim";

	private final OidcAuthService oidcAuthService;

	@Override
	public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
		OidcUser oidcUser = super.loadUser(userRequest);
		return remapPrincipalName(oidcUser, userRequest.getClientRegistration().getRegistrationId());
	}

	// Package-private so the claim resolution (merged ID-token + userinfo claims) is unit-testable
	// without the userinfo-endpoint fetch super.loadUser performs when one is configured.
	OidcUser remapPrincipalName(OidcUser oidcUser, String registrationId) {
		String usernameClaim = oidcAuthService.usernameClaimFor(registrationId);

		// getClaimAsString consults the merged ID-token + userinfo claims, matching what the
		// DefaultOidcUser constructed below resolves getName() against.
		String username = oidcUser.getClaimAsString(usernameClaim);
		if (StringUtils.isBlank(username)) {
			log.warn("OIDC provider '{}' returned no usable '{}' claim — check the registration's scopes "
					+ "and entrystore.auth.oidc.provider.{}.username-claim", registrationId, usernameClaim, registrationId);
			throw new OAuth2AuthenticationException(new OAuth2Error(ERROR_CODE_INVALID_USERNAME_CLAIM,
					"Username claim '" + usernameClaim + "' is missing or blank in the tokens from provider '"
							+ registrationId + "'", null));
		}

		// Role parity with the other SSO protocols: Spring's SAML provider grants ROLE_USER by
		// default and CasConfig grants it explicitly, while OidcUserService yields only
		// OIDC_USER/SCOPE_* authorities — without ROLE_USER an OIDC login would be denied on
		// endpoints gated by hasAnyRole(USER, ADMIN) in SecurityConfig (e.g. GET /auth/tokens).
		Set<GrantedAuthority> authorities = new LinkedHashSet<>(oidcUser.getAuthorities());
		authorities.add(new SimpleGrantedAuthority("ROLE_" + UserAuthRole.USER.name()));
		return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), usernameClaim);
	}
}