Skip to content

OpenAM MCP server#935

Open
maximthomas wants to merge 24 commits intoOpenIdentityPlatform:masterfrom
maximthomas:openam-mcp-server
Open

OpenAM MCP server#935
maximthomas wants to merge 24 commits intoOpenIdentityPlatform:masterfrom
maximthomas:openam-mcp-server

Conversation

@maximthomas
Copy link
Copy Markdown
Contributor

OpenAM MCP Server is a lightweight management service for OpenAM user accounts. It allows administrators to create, update, delete, and reset passwords for users, as well as retrieve authentication modules and chains configurations.

@maximthomas maximthomas requested a review from vharseko November 12, 2025 06:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new openam-mcp-server Spring Boot module to the OpenAM reactor, providing an MCP (Model Context Protocol) management server that can manage users/realms and read auth module/chain configuration from OpenAM (with optional OAuth2-based authentication), plus initial unit tests and test fixtures.

Changes:

  • Adds a JDK17-activated Maven reactor profile to include the new openam-mcp-server module.
  • Implements MCP tools/services for users, realms, and authentication configuration, plus request authentication via a Spring MVC interceptor.
  • Adds basic Spring Boot + service-level tests and JSON fixtures for OpenAM REST responses.

Reviewed changes

Copilot reviewed 39 out of 39 changed files in this pull request and generated 27 comments.

Show a summary per file
File Description
pom.xml Adds a JDK17-activated profile to include openam-mcp-server in the reactor build.
openam-mcp-server/pom.xml New Maven module with Spring Boot + Spring AI MCP server dependencies and build config.
openam-mcp-server/README.md Module documentation, setup instructions, and tool list/examples.
openam-mcp-server/src/main/resources/application.yml Default configuration for the MCP server and OpenAM connection/auth settings.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplication.java Spring Boot app entrypoint; RestClient + tool registration beans.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/OpenAMConfig.java @ConfigurationProperties record for OpenAM connection/auth settings.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/WebConfig.java Registers the authentication interceptor for /mcp/**.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/controller/OAuth2Controller.java Proxies OpenAM OAuth2 .well-known endpoints through this server.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java Auth flow (username/password or OAuth2), token caching, and request token propagation.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/OpenAMAbstractService.java Base service logic for retrieving the request token and default realm.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/UserService.java MCP tools for listing users, setting attributes/passwords, creating and deleting users.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/RealmService.java MCP tool for listing realms.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigService.java MCP tools for listing auth modules, chains, and available module types; schema/settings mapping.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/SearchResponseDTO.java DTO for OpenAM list/search responses.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/UserDTO.java DTO for OpenAM user JSON.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/User.java Public user model returned by MCP tools.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/RealmDTO.java DTO for OpenAM realm JSON.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/Realm.java Public realm model returned by MCP tools.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/PropertySchemaDTO.java DTO for module schema property metadata.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModuleDTO.java DTO for “all available auth module types” response.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModule.java Public model for available auth module types.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleSchemaDTO.java DTO for module schema response.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleDTO.java DTO for realm auth module instances.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModule.java Public auth module model with settings map.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChainDTO.java DTO for auth chain response.
openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChain.java Public auth chain model with resolved module names.
openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplicationTests.java Spring context smoke test.
openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/OpenAMServiceTest.java Shared RestClient/RequestContextHolder mocking setup for service tests.
openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/UserServiceTest.java Unit tests for user-related tool methods.
openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/RealmServiceTest.java Unit test for realm listing.
openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigServiceTest.java Unit tests for auth chain/module listing.
openam-mcp-server/src/test/resources/users/users-list-response.json Test fixture for OpenAM user list response.
openam-mcp-server/src/test/resources/users/user-response.json Test fixture for OpenAM user response.
openam-mcp-server/src/test/resources/realms/realms-response.json Test fixture for OpenAM realm list response.
openam-mcp-server/src/test/resources/auth/modules-response.json Test fixture for realm auth module instances response.
openam-mcp-server/src/test/resources/auth/module-settings-response.json Test fixture for module settings response.
openam-mcp-server/src/test/resources/auth/module-schema-response.json Test fixture for module schema response.
openam-mcp-server/src/test/resources/auth/chains-response.json Test fixture for realm auth chains response.
openam-mcp-server/src/test/resources/auth/all-modules-response.json Test fixture for “all available auth module types” response.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +117 to +125
long seconds = tokenValidSeconds(token);
if(seconds > 1) {
request.setAttribute("tokenId", token);
return true;
}
log.info("preHandleUsernamePassword: token {} is about to expire in {} s", token, seconds);
tokenCache.invalidate(LOGIN_PASSWORD_TOKEN_KEY);
return preHandleUsernamePassword(request);
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preHandleUsernamePassword uses recursion to refresh the token. If tokenValidSeconds() keeps returning <= 1 (e.g., because the session endpoint is down or the expiry value can't be parsed), this will recurse until stack overflow. Replace the recursion with a bounded retry loop (and fail fast with a clear error if a fresh token still can't be validated).

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +116
@Tool(name = "create_user", description = "Creates a new user")
public User createUser(@ToolParam(required = false, description = "If not set, uses root realm") String realm,
@ToolParam(description = "Username (login)") String userName,
@ToolParam(description = "Password (min length 8)") String password,
@ToolParam(required = false, description = "User family name") String familyName,
@ToolParam(required = false, description = "User given name") String givenName,
@ToolParam(required = false, description = "Name") String name,
@ToolParam(required = false, description = "Email") String mail,
@ToolParam(required = false, description = "Phone number") String phone
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createUser(...) adds non-trivial request-body construction + a POST to OpenAM, but there is no unit test covering it (unlike the other UserService tool methods). Adding a test for the payload/URI (including optional null fields) would help prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +18
username: ${OPENAM_ADMIN_USERNAME:amadmin}
password: ${OPENAM_ADMIN_PASSWORD:ampassword}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openam.username and especially openam.password have insecure defaults (amadmin / ampassword). This makes it easy to start the service with weak credentials by accident. Prefer no default (fail fast if unset) or use a clearly-invalid placeholder that forces configuration via environment/secret management.

Suggested change
username: ${OPENAM_ADMIN_USERNAME:amadmin}
password: ${OPENAM_ADMIN_PASSWORD:ampassword}
username: ${OPENAM_ADMIN_USERNAME:CHANGE_ME_OPENAM_ADMIN_USERNAME}
password: ${OPENAM_ADMIN_PASSWORD:CHANGE_ME_OPENAM_ADMIN_PASSWORD}

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +148
if (!accessTokenValid(accessToken)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setHeader("WWW-Authenticate", "Bearer realm=\"OpenAM\"");
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
return false;
}
final String token;
try {
token = tokenCache.get(accessToken, this::getTokenIdFromAccessToken);
} catch (Exception e) {
log.warn("error getting token:", e);
throw e;
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preHandleOAuth calls accessTokenValid() (userinfo request) on every incoming request, even when the access token is already cached in tokenCache. This adds an extra network round-trip per call and can become a throughput bottleneck. Consider caching the validation result (or relying on the token->session exchange result) with an expiry aligned to the access token lifetime.

Suggested change
if (!accessTokenValid(accessToken)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setHeader("WWW-Authenticate", "Bearer realm=\"OpenAM\"");
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
return false;
}
final String token;
try {
token = tokenCache.get(accessToken, this::getTokenIdFromAccessToken);
} catch (Exception e) {
log.warn("error getting token:", e);
throw e;
}
final String token;
try {
token = tokenCache.get(accessToken, this::getTokenIdFromAccessToken);
} catch (Exception e) {
log.warn("preHandleOAuth: error getting token for access token {}: {}", accessToken, e.getMessage());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setHeader("WWW-Authenticate", "Bearer realm=\"OpenAM\"");
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
return false;
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +73 to +79
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spring-boot-starter-parent is intended to be used as a Maven parent, not as a BOM imported via dependencyManagement. Importing it can lead to unexpected build/plugin configuration and incomplete dependency management. Prefer importing org.springframework.boot:spring-boot-dependencies as a BOM (or make Spring Boot the parent if feasible).

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +176
log.error("preHandleOAuth: unable to obtain a valid access_token after {} attempts; " +
"the session endpoint may be unavailable or the token TTL cannot be determined.", MAX_TOKEN_RESOLUTION_ATTEMPTS);
throw new IllegalStateException(
"Failed to obtain a valid OpenAM access_token after " + MAX_TOKEN_RESOLUTION_ATTEMPTS + " attempts. " +
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception message says "OpenAM access_token" but this code path is caching/validating an OpenAM session token obtained by exchanging the OAuth access token. Updating the wording would make troubleshooting clearer (and avoid confusing access tokens vs OpenAM session tokens).

Suggested change
log.error("preHandleOAuth: unable to obtain a valid access_token after {} attempts; " +
"the session endpoint may be unavailable or the token TTL cannot be determined.", MAX_TOKEN_RESOLUTION_ATTEMPTS);
throw new IllegalStateException(
"Failed to obtain a valid OpenAM access_token after " + MAX_TOKEN_RESOLUTION_ATTEMPTS + " attempts. " +
log.error("preHandleOAuth: unable to obtain a valid OpenAM session token derived from the access token after {} attempts; " +
"the session endpoint may be unavailable or the token TTL cannot be determined.", MAX_TOKEN_RESOLUTION_ATTEMPTS);
throw new IllegalStateException(
"Failed to obtain a valid OpenAM session token derived from the provided OAuth access token after " + MAX_TOKEN_RESOLUTION_ATTEMPTS + " attempts. " +

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +106
List<AuthChainDTO> getRealmAuthChains(String realm, String tokenId) {
final String chainsUri = String.format("/json/realms/%s/realm-config/authentication/chains?_queryFilter=true", realm);
SearchResponseDTO<AuthChainDTO> authChainsResponse = openAMRestClient.get().uri(chainsUri)
.header(openAMConfig.tokenHeader(), tokenId)
.retrieve()
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realm/module identifiers are interpolated into URLs without encoding. This will break for nested realms (e.g. values containing '/') or any IDs with reserved characters, and is inconsistent with UserService which uses UriUtils.encodePath. Consider encoding realm, moduleType, and moduleId when building these URLs.

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +61
public AuthInterceptor(RestClient openAMRestClient, OpenAMConfig openAMConfig) {
this(openAMRestClient, openAMConfig, Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES).build());
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Caffeine cache is unbounded (no maximumSize/maximumWeight). In OAuth mode the cache key is the incoming access token, so high token cardinality can cause unbounded memory growth over the 30-minute retention window. Consider setting a max size (and ideally making it configurable) to cap memory usage.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +86
} catch (HttpClientErrorException e) {
return ResponseEntity.status(e.getStatusCode())
.headers(e.getResponseHeaders())
.body(e.getResponseBodyAs(String.class));
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the HttpClientErrorException handler, e.getResponseHeaders() can be null (Spring marks it @nullable). Passing null into ResponseEntity.headers(...) can throw a NullPointerException and mask the real upstream error. Consider guarding for null (use empty headers when missing) before building the response.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants