refs #744: add AD OIDC MCP identity bridge

This commit is contained in:
devmrko
2026-08-04 14:24:07 +09:00
parent 9f46205057
commit d868d7bbb8
13 changed files with 427 additions and 7 deletions

View File

@@ -42,6 +42,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>

View File

@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice;
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
import com.cloudhandson.ddsbackoffice.config.DdsMcpOidcProperties;
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
import com.cloudhandson.vpdbackoffice.web.DashboardController;
import com.cloudhandson.vpdbackoffice.web.LoginController;
@@ -15,7 +16,7 @@ import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
@SpringBootApplication
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class, DdsMcpOidcProperties.class})
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
@ComponentScan(
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},

View File

@@ -0,0 +1,29 @@
package com.cloudhandson.ddsbackoffice.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
@Configuration
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
public class DdsMcpOidcConfiguration {
@Bean
JwtDecoder ddsMcpJwtDecoder(DdsMcpOidcProperties properties) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(properties.issuerUri()).build();
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> jwt.getAudience().contains(properties.audience())
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.issuerUri()), audienceValidator));
return decoder;
}
}

View File

@@ -0,0 +1,22 @@
package com.cloudhandson.ddsbackoffice.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/** OIDC resource-server settings for externally authenticated MCP callers. */
@ConfigurationProperties(prefix = "dds.mcp.oidc")
public record DdsMcpOidcProperties(boolean enabled, String issuerUri, String audience) {
@ConstructorBinding
public DdsMcpOidcProperties {
issuerUri = trim(issuerUri);
audience = trim(audience);
if (enabled && (issuerUri.isBlank() || audience.isBlank())) {
throw new IllegalArgumentException("DDS MCP OIDC issuer-uri와 audience를 설정해야 합니다.");
}
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -1,6 +1,6 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessResourceFailureException;
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
@Component
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
public class DdsMcpAuthorizationChangeListener implements ExternalAuthorizationSynchronizer {
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
private final DdsMcpEndUserPublisher publisher;

View File

@@ -9,6 +9,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
/** Revalidates the MCP request bearer before every tool invocation. */
@@ -20,22 +21,32 @@ public class DdsMcpBearerAuthenticator {
private final BearerTokenService bearerTokenService;
private final UserMapper userMapper;
private final DdsMcpEndUserResolver endUserResolver;
private final ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator;
private final Clock clock;
public DdsMcpBearerAuthenticator(
BearerTokenService bearerTokenService,
UserMapper userMapper,
DdsMcpEndUserResolver endUserResolver,
ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator,
Clock clock
) {
this.bearerTokenService = bearerTokenService;
this.userMapper = userMapper;
this.endUserResolver = endUserResolver;
this.oidcAuthenticator = oidcAuthenticator;
this.clock = clock;
}
public DdsMcpAuthenticatedUser authenticate(String authorization) {
String plainToken = bearerValue(authorization);
if (plainToken.chars().filter(character -> character == '.').count() == 2) {
DdsMcpOidcAuthenticator oidc = oidcAuthenticator.getIfAvailable();
if (oidc == null) {
throw denied();
}
return oidc.authenticate(plainToken);
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {

View File

@@ -0,0 +1,51 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.vpdbackoffice.service.AppException;
import java.util.List;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/** Maps a verified OIDC issuer and subject to one active HMM application user. */
@Service
public class DdsMcpExternalIdentityResolver {
private final JdbcTemplate jdbcTemplate;
public DdsMcpExternalIdentityResolver(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public long resolveApplicationUserId(String issuer, String subject) {
if (!safe(issuer) || !safe(subject)) {
throw denied();
}
try {
List<Long> userIds = jdbcTemplate.query("""
SELECT b.application_user_id
FROM cb_external_identity_binding b
JOIN cb_app_user u ON u.user_id = b.application_user_id
WHERE b.issuer = ?
AND b.subject = ?
AND b.active = 'Y'
AND u.active = 'Y'
""", (row, ignored) -> row.getLong("application_user_id"), issuer, subject);
if (userIds.size() != 1) {
throw denied();
}
return userIds.getFirst();
} catch (AppException exception) {
throw exception;
} catch (DataAccessException exception) {
throw new AppException("DDS_CONTEXT_UNAVAILABLE: 외부 ID 매핑을 확인할 수 없습니다.");
}
}
private boolean safe(String value) {
return value != null && !value.isBlank() && value.length() <= 512;
}
private AppException denied() {
return new AppException("AUTHORIZATION_DENIED: 외부 OIDC 사용자를 DDS에 매핑할 수 없습니다.");
}
}

View File

@@ -0,0 +1,55 @@
package com.cloudhandson.ddsbackoffice.service;
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import com.cloudhandson.vpdbackoffice.service.AppException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service;
/** Validates a signed OIDC JWT before looking up the DDS identity bridge. */
@Service
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
public class DdsMcpOidcAuthenticator {
private final JwtDecoder jwtDecoder;
private final DdsMcpExternalIdentityResolver externalIdentityResolver;
private final UserMapper userMapper;
private final DdsMcpEndUserResolver endUserResolver;
public DdsMcpOidcAuthenticator(
JwtDecoder jwtDecoder,
DdsMcpExternalIdentityResolver externalIdentityResolver,
UserMapper userMapper,
DdsMcpEndUserResolver endUserResolver
) {
this.jwtDecoder = jwtDecoder;
this.externalIdentityResolver = externalIdentityResolver;
this.userMapper = userMapper;
this.endUserResolver = endUserResolver;
}
public DdsMcpAuthenticatedUser authenticate(String token) {
try {
Jwt jwt = jwtDecoder.decode(token);
long userId = externalIdentityResolver.resolveApplicationUserId(
jwt.getIssuer() == null ? null : jwt.getIssuer().toString(), jwt.getSubject());
AppUser user = userMapper.findById(userId);
if (user == null || !user.active()) {
throw denied();
}
return new DdsMcpAuthenticatedUser(user.userId(), user.username(), endUserResolver.resolve(user.userId()));
} catch (AppException exception) {
throw exception;
} catch (JwtException | IllegalArgumentException exception) {
throw denied();
}
}
private AppException denied() {
return new AppException("AUTHORIZATION_DENIED: MCP OIDC 토큰을 확인할 수 없습니다.");
}
}

View File

@@ -58,6 +58,12 @@ backoffice:
dds:
mcp:
permission-sync-enabled: true
oidc:
# Disabled unless an external OIDC issuer is explicitly configured.
# The production issuer must use HTTPS; the isolated AD VM uses a temporary HTTP value only for PoC.
enabled: ${DDS_MCP_OIDC_ENABLED:false}
issuer-uri: ${DDS_MCP_OIDC_ISSUER_URI:}
audience: ${DDS_MCP_OIDC_AUDIENCE:dds-mcp}
iam:
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}

View File

@@ -3,8 +3,8 @@ package com.cloudhandson.ddsbackoffice.service;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier;
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationChangeNotifier;
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
@@ -16,9 +16,9 @@ class DdsMcpAuthorizationChangeListenerTest {
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.registerSingleton("ddsSynchronizer", (DdsAuthorizationSynchronizer) listener);
factory.registerSingleton("ddsSynchronizer", (ExternalAuthorizationSynchronizer) listener);
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
new ExternalAuthorizationChangeNotifier(factory.getBeanProvider(ExternalAuthorizationSynchronizer.class))
.changed("PERMISSION_SAVED");
verify(publisher).publish();