diff --git a/openam-mcp-server/README.md b/openam-mcp-server/README.md new file mode 100644 index 0000000000..ef9f7326ee --- /dev/null +++ b/openam-mcp-server/README.md @@ -0,0 +1,346 @@ +# OpenAM MCP Server + +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. + +## Prerequisites +* JDK 17+ +* [OpenAM](http://github.com/OpenIdentityPlatform/OpenAM) installed + +## Quick Start +Set the `OPENAM_URL` environment variable, i.e., http://openam.example.org:8080/openam +Set the `OPENAM_ADMIN_USERNAME` (i.e., `amadmin`) and the `OPENAM_ADMIN_PASSWORD` environment variables (i.e., `passw0rd`). + +```bash +export OPENAM_URL=http://openam.example.org:8080/openam +export OPENAM_ADMIN_USERNAME=amadmin +export OPENAM_ADMIN_PASSWORD=passw0rd +``` + +Clone and run from source: + +```bash +mvn spring-boot:run +``` +Or build and run the JAR: + +```bash +cd openam-mcp-server +mvn package -DskipTests=true && java -jar ./target/openam-mcp-server-*.jar +``` + +## Advanced Authentication + +> [!IMPORTANT] +> UUsing administrative credentials directly in the MCP server can be insecure. This server therefore supports OpenAM's OAuth 2.0 protocol. +protocol. + +This approach requires additional OpenAM configuration. + +### OpenAM OAuth2.0 Service Configuration + +1. In the OpenAM admin console, select the root realm. +1. Select **Configure OAuth Provider** → **Configure OAuth2.0**. +1. Leave the settings unchanged and click **Create**. + +Configure the OAuth 2.0 Provider with the following settings: + +| Setting | Value | +|------------------------------------------------------------------|---------| +| Use Stateless Access & Refresh Tokens | enabled | +| User Profile Attribute(s) the Resource Owner is Authenticated On | uid | +| Supported Scopes | profile | +| OAuth2 Token Signing Algorithm | RS256 | +| Allow Open Dynamic Client Registration | enabled | + +For more details, see the OpenAM OAuth 2.0 documentation:: https://doc.openidentityplatform.org/openam/admin-guide/chap-oauth2 + +### Authentication Chain Configuration + +Create an OpenAM OAuth 2.0 authentication chain so the MCP server can exchange an access token for an SSO token to manage identities. + +1. In the admin console, select the root realm. +1. In the left menu go to **Authentication** → **Modules** and create a new module with named `oidc` of type `OpenID Connect id_token bearer`. + +Configure the `oidc` module as follows: + +| Setting | Value | +|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| OpenID Connect validation configuration value | OpenAM's .well-known/openid-configuration endpoint, i.e., http://openam.example.org:8080/openam/oauth2/.well-known/openid-configuration | +| Name of OpenID Connect ID Token Issuer | http://openam.example.org:8080/openam/oauth2 | +| Mapping of jwt attributes to local LDAP attributes | sub=uid | +| Audience name | Your MCP client's client ID, for example openam-mcp-server | + +Create an authentication chain: + +1. In the admin console, select the root realm. +1. In the left menu select **Authentication** → **Chains** +1. Create a new chain named `oidc` with the following configuration: + +| Module | Criteria | +|--------|------------| +| oidc | REQUISITE | + +Finally, enable OAuth 2.0 in the MCP server: + +```bash +export OPENAM_USE_OAUTH=true +``` + +## Available MCP Server Tools + +### Authentication Service Tools + +```java +@Tool(name = "get_auth_modules", description = "Returns OpenAM authentication modules list") +public List getAuthModules(@ToolParam(required = false, description = "If not set, uses root realm") String realm) + +@Tool(name = "get_auth_chains", description = "Returns OpenAM authentication chains with modules") +public List getOpenAMAuthChains(@ToolParam(required = false, description = "If not set, uses root realm") String realm) + +@Tool(name = "get_available_modules", description = "Returns all available authentication modules") +public List getAvailableModuleList() + + +``` + +### Realm Tools + +```java +@Tool(name = "get_realms", description = "Returns OpenAM realm list") +public List getRealms() +``` + +### User Tools + +```java +@Tool(name = "get_users", description = "Returns OpenAM user list from the default (root) realm") +public List getUsers(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(required = false, description = "Username filter") String filter) + +@Tool(name = "set_user_attribute", description = "Sets the attribute value for a user") +public User setUserAttribute(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(description = "username") String username, + @ToolParam(description = "user attribute name") String attribute, + @ToolParam(description = "user attribute value") String value) + +@Tool(name = "set_user_password", description = "Sets the password for a user") +public User setUserPassword(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(description = "username") String username, + @ToolParam(description = "user password") String password) + +@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) + +@Tool(name = "delete_user", description = "Deletes a user") +public Map deleteUser(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(description = "Username (login)") String username) + +``` + +In JSON-RPC format: +```json +{ + "tools": [ + { + "name": "set_user_password", + "description": "Sets the password for a user", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + }, + "arg1": { + "type": "string", + "description": "username" + }, + "arg2": { + "type": "string", + "description": "user password" + } + }, + "required": [ + "arg1", + "arg2" + ], + "additionalProperties": false + } + }, + { + "name": "set_user_attribute", + "description": "Sets the attribute value for a user", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + }, + "arg1": { + "type": "string", + "description": "username" + }, + "arg2": { + "type": "string", + "description": "user attribute name" + }, + "arg3": { + "type": "string", + "description": "user attribute value" + } + }, + "required": [ + "arg1", + "arg2", + "arg3" + ], + "additionalProperties": false + } + }, + { + "name": "get_realms", + "description": "Returns OpenAM realm list", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "get_users", + "description": "Returns OpenAM user list from the default (root) realm", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + }, + "arg1": { + "type": "string", + "description": "Username filter" + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "get_auth_modules", + "description": "Returns OpenAM authentication modules list", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "create_user", + "description": "Creates a new user", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + }, + "arg1": { + "type": "string", + "description": "Username (login)" + }, + "arg2": { + "type": "string", + "description": "Password (min length 8)" + }, + "arg3": { + "type": "string", + "description": "User family name" + }, + "arg4": { + "type": "string", + "description": "User given name" + }, + "arg5": { + "type": "string", + "description": "Name" + }, + "arg6": { + "type": "string", + "description": "Email" + }, + "arg7": { + "type": "string", + "description": "Phone number" + } + }, + "required": [ + "arg1", + "arg2" + ], + "additionalProperties": false + } + }, + { + "name": "delete_user", + "description": "Deletes a user", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + }, + "arg1": { + "type": "string", + "description": "Username (login)" + } + }, + "required": [ + "arg1" + ], + "additionalProperties": false + } + }, + { + "name": "get_available_modules", + "description": "Returns all available authenticaion modules", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "get_auth_chains", + "description": "Returns OpenAM authentication chains with modules", + "inputSchema": { + "type": "object", + "properties": { + "arg0": { + "type": "string", + "description": "If not set, uses root realm" + } + }, + "required": [], + "additionalProperties": false + } + } + ] +} +``` diff --git a/openam-mcp-server/pom.xml b/openam-mcp-server/pom.xml new file mode 100644 index 0000000000..e2b4aff3dc --- /dev/null +++ b/openam-mcp-server/pom.xml @@ -0,0 +1,120 @@ + + + + 4.0.0 + + org.openidentityplatform.openam + openam + 16.0.4-SNAPSHOT + + openam-mcp-server + OpenAM MCP Server + MCP server for OpenAM management + + 17 + 4.0.1 + 1.1.2 + ${java.version} + ${java.version} + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + com.github.ben-manes.caffeine + caffeine + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.boot + spring-boot-starter-parent + ${spring-boot.version} + pom + import + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + + ${basedir}/src/main/resources + true + + **/application*.yml + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + ^ + + false + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + true + + + + + + diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplication.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplication.java new file mode 100644 index 0000000000..4558dae9bf --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplication.java @@ -0,0 +1,53 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server; + +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.openidentityplatform.openam.mcp.server.service.AuthenticationConfigService; +import org.openidentityplatform.openam.mcp.server.service.RealmService; +import org.openidentityplatform.openam.mcp.server.service.UserService; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.RestClient; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class OpenAmMcpServerApplication { + public static void main(String[] args) { + SpringApplication.run(OpenAmMcpServerApplication.class, args); + } + + @Bean + public RestClient getOpenAMRestClient(OpenAMConfig openAMConfig) { + return RestClient.builder().baseUrl(openAMConfig.url()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json").build(); + } + + @Bean + public ToolCallbackProvider getTools(UserService userService, + RealmService realmService, + AuthenticationConfigService configService) { + return MethodToolCallbackProvider.builder().toolObjects(userService, realmService, configService).build(); + } +} + + diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/OpenAMConfig.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/OpenAMConfig.java new file mode 100644 index 0000000000..0c32aa7716 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/OpenAMConfig.java @@ -0,0 +1,33 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("openam") +public record OpenAMConfig( + String url, + + boolean useOAuthForAuthentication, + + String oidcAuthChain, + String oidcAuthHeader, + + String tokenHeader, + String username, + String password +) {} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/WebConfig.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/WebConfig.java new file mode 100644 index 0000000000..6768e605aa --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/config/WebConfig.java @@ -0,0 +1,38 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.config; + +import org.openidentityplatform.openam.mcp.server.security.AuthInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final AuthInterceptor authInterceptor; + + public WebConfig(AuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/mcp") + .addPathPatterns("/mcp/**"); + } +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/controller/OAuth2Controller.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/controller/OAuth2Controller.java new file mode 100644 index 0000000000..fc94047240 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/controller/OAuth2Controller.java @@ -0,0 +1,65 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +import java.util.Set; + +@RestController +public class OAuth2Controller { + private final RestClient openAMRestClient; + + public OAuth2Controller(RestClient openAMRestClient) { + this.openAMRestClient = openAMRestClient; + } + + private static final Set IGNORE_HEADERS = Set.of( + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade" + ); + + @GetMapping("/.well-known/**") + public ResponseEntity openAMWellKnown(HttpServletRequest request) { + RestClient.RequestBodySpec requestSpec = openAMRestClient + .method(HttpMethod.valueOf(request.getMethod())) + .uri("/oauth2".concat(request.getRequestURI())) + .headers(headers -> request.getHeaderNames().asIterator().forEachRemaining(name -> { + if (IGNORE_HEADERS.contains(name.toLowerCase())) { + return; + } + String value = request.getHeader(name); + headers.add(name, value); + })); + try { + return requestSpec.retrieve() + .toEntity(String.class); + } catch (HttpClientErrorException e) { + return ResponseEntity.status(e.getStatusCode()) + .headers(e.getResponseHeaders()) + .body(e.getResponseBodyAs(String.class)); + } + + } + +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChain.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChain.java new file mode 100644 index 0000000000..1dffe4684a --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChain.java @@ -0,0 +1,22 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import java.util.List; + +public record AuthChain(String id, List modules) { +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChainDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChainDTO.java new file mode 100644 index 0000000000..2912b7915e --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthChainDTO.java @@ -0,0 +1,27 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record AuthChainDTO(@JsonProperty("_id") String id, + @JsonProperty("authChainConfiguration") List modules) { + + public record AuthChainModuleDTO(String module) {} +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModule.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModule.java new file mode 100644 index 0000000000..45d4d8f295 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModule.java @@ -0,0 +1,21 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import java.util.Map; + +public record AuthModule(String name, Map settings){} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleDTO.java new file mode 100644 index 0000000000..9df81d1083 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleDTO.java @@ -0,0 +1,21 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AuthModuleDTO(@JsonProperty("_id") String id, String typeDescription, String type) {} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleSchemaDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleSchemaDTO.java new file mode 100644 index 0000000000..0ff5d5660b --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/AuthModuleSchemaDTO.java @@ -0,0 +1,22 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import java.util.Map; + +public record AuthModuleSchemaDTO(String type, Map properties) { +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModule.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModule.java new file mode 100644 index 0000000000..6f46478948 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModule.java @@ -0,0 +1,23 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +public record CoreAuthModule(String id, String name) { + public CoreAuthModule(CoreAuthModuleDTO coreAuthModuleDTO) { + this(coreAuthModuleDTO.id(), coreAuthModuleDTO.name()); + } +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModuleDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModuleDTO.java new file mode 100644 index 0000000000..df6cf30e65 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/CoreAuthModuleDTO.java @@ -0,0 +1,21 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record CoreAuthModuleDTO(@JsonProperty("_id") String id, String name) {} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/PropertySchemaDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/PropertySchemaDTO.java new file mode 100644 index 0000000000..565298f7a8 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/PropertySchemaDTO.java @@ -0,0 +1,19 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +public record PropertySchemaDTO(String title, String description) {} \ No newline at end of file diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/Realm.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/Realm.java new file mode 100644 index 0000000000..a475f4a7d7 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/Realm.java @@ -0,0 +1,31 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import java.util.ArrayList; +import java.util.List; + +public record Realm(String name, + boolean active, + String parentPath, + List aliases) { + public Realm(RealmDTO realmDTO) { + this(realmDTO.name().equals("/") ? "root" : realmDTO.name(), + realmDTO.active(), realmDTO.parentPath(), + new ArrayList<>(realmDTO.aliases())); + } +} \ No newline at end of file diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/RealmDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/RealmDTO.java new file mode 100644 index 0000000000..f5732224d3 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/RealmDTO.java @@ -0,0 +1,28 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +public record RealmDTO(@JsonProperty("_id") String id, + @JsonProperty("parentPath") String parentPath, + @JsonProperty("active") boolean active, + @JsonProperty("name") String name, + @JsonProperty("aliases") List aliases) { + +} \ No newline at end of file diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/SearchResponseDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/SearchResponseDTO.java new file mode 100644 index 0000000000..693b958e04 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/SearchResponseDTO.java @@ -0,0 +1,29 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record SearchResponseDTO(@JsonProperty("result") List result, + @JsonProperty("resultCount") int resultCount, + @JsonProperty("pagedResultsCookie") String pagedResultsCookie, + @JsonProperty("totalPagedResultsPolicy") String totalPagedResultsPolicy, + @JsonProperty("totalPagedResults") int totalPagedResults, + @JsonProperty("remainingPagedResults") int remainingPagedResults) { +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/User.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/User.java new file mode 100644 index 0000000000..64c699960a --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/User.java @@ -0,0 +1,43 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import java.util.List; + +public record User(String userName, + String familyName, + String givenName, + String name, + String mail, + String phone +) { + public User(UserDTO userDTO) { + this(userDTO.userName(), + singleValue(userDTO.sn()), + singleValue(userDTO.givenName()), + singleValue(userDTO.cn()), + singleValue(userDTO.mail()), + singleValue(userDTO.telephoneNumber())); + } + + private static String singleValue(List vals) { + if (vals == null || vals.isEmpty()) { + return null; + } + return vals.iterator().next(); + } +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/UserDTO.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/UserDTO.java new file mode 100644 index 0000000000..b410cc61b0 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/model/UserDTO.java @@ -0,0 +1,46 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record UserDTO( + @JsonProperty("username") + String userName, + + @JsonProperty("sn") + List sn, + + @JsonProperty("givenName") + List givenName, + + @JsonProperty("cn") + List cn, + + @JsonProperty("employeeNumber") + List employeeNumber, + + @JsonProperty("telephoneNumber") + List telephoneNumber, + + @JsonProperty("mail") + List mail + +) {} + diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java new file mode 100644 index 0000000000..0aedbb2942 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java @@ -0,0 +1,180 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.security; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.io.IOException; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private static final String LOGIN_PASSWORD_TOKEN_KEY = "login-password-token"; + + private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class); + + private final RestClient openAMRestClient; + + private final OpenAMConfig openAMConfig; + + + private final Cache tokenCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.MINUTES).build(); + + public AuthInterceptor(RestClient openAMRestClient, OpenAMConfig openAMConfig) { + this.openAMRestClient = openAMRestClient; + this.openAMConfig = openAMConfig; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if(openAMConfig.useOAuthForAuthentication()) { + return preHandleOAuth(request, response); + } else { + return preHandleUsernamePassword(request); + } + } + + private long tokenValidSeconds(String tokenId) { + String sessionUri = "/json/sessions/?_action=getSessionInfo"; + try { + Map tokenProps = openAMRestClient.post() + .uri(sessionUri) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve().body(new ParameterizedTypeReference<>() { + }); + String expTime = tokenProps.get("maxIdleExpirationTime"); + ZonedDateTime dateTime = ZonedDateTime.parse(expTime); + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + Duration duration = Duration.between(now, dateTime); + return duration.getSeconds(); + } catch (Exception e) { + log.warn("error getting token properties: ", e); + return -1; + } + } + + private String getUserNamePasswordToken() { + Map tokenResponse = openAMRestClient.post().uri("/json/authenticate") + .header("X-OpenAM-Username", openAMConfig.username()) + .header("X-OpenAM-Password", openAMConfig.password()) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + return tokenResponse.get("tokenId"); + } + + private String getTokenIdFromAccessToken(String accessToken) { + Map tokenResponse = openAMRestClient.post() + .uri("/json/authenticate?authIndexType=service&authIndexValue=".concat(openAMConfig.oidcAuthChain())) + .header(openAMConfig.oidcAuthHeader(), accessToken) + .body("{}") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + return tokenResponse.get("tokenId"); + } + + private boolean preHandleUsernamePassword(HttpServletRequest request) { + final String token; + try { + token = tokenCache.get(LOGIN_PASSWORD_TOKEN_KEY, (k) -> getUserNamePasswordToken()); + } catch (Exception e) { + log.warn("preHandleUsernamePassword: error getting token:", e); + throw e; + } + 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); + } + + private boolean preHandleOAuth(HttpServletRequest request, HttpServletResponse response) throws IOException { + + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return false; + } + String accessToken = authHeader.substring(7); + + 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; + } + long seconds = tokenValidSeconds(token); + if(seconds > 1) { + request.setAttribute("tokenId", token); + return true; + } + log.info("preHandleOAuth: token {} is about to expire in {} s", token, seconds); + tokenCache.invalidate(accessToken); + return preHandleOAuth(request, response); + } + + private boolean accessTokenValid(String accessToken) { + + //validate access token + try { + Map response = openAMRestClient.get() + .uri("/oauth2/userinfo") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + if(response.containsKey("name")) { + return true; + } else { + log.warn("got invalid response: {} for access token: {}", response, accessToken); + return false; + } + } catch (Exception e) { + log.warn("Token validation failed: {}", e.getMessage()); + return false; + } + } +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigService.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigService.java new file mode 100644 index 0000000000..82ca46bc65 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigService.java @@ -0,0 +1,140 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.openidentityplatform.openam.mcp.server.model.AuthChain; +import org.openidentityplatform.openam.mcp.server.model.AuthChainDTO; +import org.openidentityplatform.openam.mcp.server.model.AuthModule; +import org.openidentityplatform.openam.mcp.server.model.CoreAuthModule; +import org.openidentityplatform.openam.mcp.server.model.CoreAuthModuleDTO; +import org.openidentityplatform.openam.mcp.server.model.AuthModuleDTO; +import org.openidentityplatform.openam.mcp.server.model.PropertySchemaDTO; +import org.openidentityplatform.openam.mcp.server.model.AuthModuleSchemaDTO; +import org.openidentityplatform.openam.mcp.server.model.SearchResponseDTO; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class AuthenticationConfigService extends OpenAMAbstractService { + public AuthenticationConfigService(RestClient openAMRestClient, OpenAMConfig openAMConfig) { + super(openAMRestClient, openAMConfig); + } + + @Tool(name = "get_auth_modules", description = "Returns OpenAM authentication modules list") + public List getAuthModules(@ToolParam(required = false, description = "If not set, uses root realm") String realm) { + realm = getRealmOrDefault(realm); + String tokenId = getTokenId(); + + List realmAuthModules = getRealmAuthModules(realm, tokenId); + List authModules = new ArrayList<>(); + for(AuthModuleDTO authModuleDTO : realmAuthModules) { + Map schema = getModuleSchema(tokenId, realm, authModuleDTO.type()); + Map settings = getModuleSettings(tokenId, realm, authModuleDTO.id(), authModuleDTO.type()); + + var moduleSettings = new HashMap(); + for (var prop : schema.entrySet()) { + var setting = settings.get(prop.getKey()); + var settingName = prop.getValue().title(); + moduleSettings.put(settingName, setting); + } + var authModuleId = authModuleDTO.typeDescription(); + authModules.add(new AuthModule(authModuleId, moduleSettings)); + } + return authModules; + } + + @Tool(name = "get_auth_chains", description = "Returns OpenAM authentication chains with modules") + public List getOpenAMAuthChains(@ToolParam(required = false, description = "If not set, uses root realm") String realm) { + realm = getRealmOrDefault(realm); + String tokenId = getTokenId(); + + final List chainsDTOList = getRealmAuthChains(realm, tokenId); + final List authModuleDTOList = getRealmAuthModules(realm, tokenId); + + return chainsDTOList.stream().map(chainDTO -> { + List chainModules = chainDTO.modules().stream().map(AuthChainDTO.AuthChainModuleDTO::module).toList(); + List moduleNames = chainModules.stream() + .map(cm -> authModuleDTOList.stream() + .filter(rm -> cm.equals(rm.id())).findFirst().get().typeDescription()).toList(); + return new AuthChain(chainDTO.id(), moduleNames); + }).collect(Collectors.toList()); + } + + @Tool(name = "get_available_modules", description = "Returns all available authentication modules") + public List getAvailableModuleList() { + String tokenId = getTokenId(); + String coreModulesUri = "/json/global-config/authentication/modules?_action=getAllTypes"; + SearchResponseDTO coreAuthModulesResponse = openAMRestClient.post().uri(coreModulesUri) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + return coreAuthModulesResponse.result().stream().map(CoreAuthModule::new).collect(Collectors.toList()); + } + + List getRealmAuthChains(String realm, String tokenId) { + final String chainsUri = String.format("/json/realms/%s/realm-config/authentication/chains?_queryFilter=true", realm); + SearchResponseDTO authChainsResponse = openAMRestClient.get().uri(chainsUri) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + return authChainsResponse.result(); + } + + List getRealmAuthModules(String realm, String tokenId) { + final String modulesUri = String.format("/json/realms/%s/realm-config/authentication/modules?_queryFilter=true", realm); + SearchResponseDTO modulesResponse = openAMRestClient.get().uri(modulesUri) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + return modulesResponse.result(); + } + + Map getModuleSchema(String tokenId, String realm, String moduleType) { + + String schemaUrl = String.format( "/json/realms/%s/realm-config/authentication/modules/%s?_action=schema", realm, moduleType); + AuthModuleSchemaDTO authModuleSchemaDTO = openAMRestClient.post().uri(schemaUrl) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(AuthModuleSchemaDTO.class); + + return authModuleSchemaDTO.properties(); + } + + Map getModuleSettings(String tokenId, String realm, String moduleId, String moduleType) { + String settingsUrl = String.format("/json/realms/%s/realm-config/authentication/modules/%s/%s", realm, moduleType, moduleId); + + return openAMRestClient.get().uri(settingsUrl) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + } +} + + diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/OpenAMAbstractService.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/OpenAMAbstractService.java new file mode 100644 index 0000000000..70ccc286b2 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/OpenAMAbstractService.java @@ -0,0 +1,46 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.springframework.web.client.RestClient; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +public abstract class OpenAMAbstractService { + + protected final RestClient openAMRestClient; + + protected final OpenAMConfig openAMConfig; + + protected final static String DEFAULT_REALM = "root"; + + public OpenAMAbstractService(RestClient openAMRestClient, OpenAMConfig openAMConfig) { + this.openAMRestClient = openAMRestClient; + this.openAMConfig = openAMConfig; + } + + protected String getTokenId() { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + return (String) attrs.getAttribute("tokenId", RequestAttributes.SCOPE_REQUEST); + } + + protected String getRealmOrDefault(String realm) { + return realm != null ? realm : DEFAULT_REALM; + } + +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/RealmService.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/RealmService.java new file mode 100644 index 0000000000..2b2cd27942 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/RealmService.java @@ -0,0 +1,48 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.openidentityplatform.openam.mcp.server.model.Realm; +import org.openidentityplatform.openam.mcp.server.model.RealmDTO; +import org.openidentityplatform.openam.mcp.server.model.SearchResponseDTO; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class RealmService extends OpenAMAbstractService { + public RealmService(RestClient openAMRestClient, OpenAMConfig openAMConfig) { + super(openAMRestClient, openAMConfig); + } + + @Tool(name = "get_realms", description = "Returns OpenAM realm list") + public List getRealms() { + String uri = "/json/global-config/realms?_queryFilter=true"; + String tokenId = getTokenId(); + SearchResponseDTO realmsResponse = openAMRestClient.get().uri(uri) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + return realmsResponse.result().stream().map(Realm::new).collect(Collectors.toList()); + } +} diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/UserService.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/UserService.java new file mode 100644 index 0000000000..faf5a12549 --- /dev/null +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/service/UserService.java @@ -0,0 +1,166 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.openidentityplatform.openam.mcp.server.model.SearchResponseDTO; +import org.openidentityplatform.openam.mcp.server.model.User; +import org.openidentityplatform.openam.mcp.server.model.UserDTO; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class UserService extends OpenAMAbstractService { + public UserService(RestClient openAMRestClient, OpenAMConfig openAMConfig) { + super(openAMRestClient, openAMConfig); + } + + @Tool(name = "get_users", description = "Returns OpenAM user list from the default (root) realm") + public List getUsers(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(required = false, description = "Username filter") String filter) { + + realm = getRealmOrDefault(realm); + + String queryFilter = "true"; + if(filter != null) { + queryFilter= "cn sw \"".concat(filter).concat("\""); + } + String uri = String.format("/json/realms/%s/users?_queryFilter=%s", realm, queryFilter); + String tokenId = getTokenId(); + SearchResponseDTO userSearchResponse = openAMRestClient.get().uri(uri) + .header(openAMConfig.tokenHeader(), tokenId) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + return userSearchResponse.result().stream().map(User::new).collect(Collectors.toList()); + } + + private static final Map ATTR_MAP = Map.of("familyName", "sn", + "givenName", "givenName", + "name", "cn", + "mail", "mail", + "phone", "telephoneNumber"); + + @Tool(name = "set_user_attribute", description = "Sets the attribute value for a user") + public User setUserAttribute(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(description = "username") String username, + @ToolParam(description = "user attribute name") String attribute, + @ToolParam(description = "user attribute value") String value) { + + realm = getRealmOrDefault(realm); + + if(!ATTR_MAP.containsKey(attribute)) { + throw new RuntimeException(String.format("invalid attribute: %s; allowed values %s", attribute, ATTR_MAP.keySet())); + } + String tokenId = getTokenId(); + + Map requestBody = Map.of(ATTR_MAP.get(attribute), value); + String uri = String.format("/json/realms/%s/users/%s", realm, username); + UserDTO user = openAMRestClient.put().uri(uri).body(requestBody) + .header(openAMConfig.tokenHeader(), tokenId) + .header("Accept-API-Version","resource=2.0, protocol=1.0") + .retrieve() + .body(UserDTO.class); + return new User(user); + } + + + @Tool(name = "set_user_password", description = "Sets the password for a user") + public User setUserPassword(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(description = "username") String username, + @ToolParam(description = "user password") String password) { + + realm = getRealmOrDefault(realm); + + String tokenId = getTokenId(); + + Map requestBody = Map.of("userpassword", password); + String uri = String.format("/json/realms/%s/users/%s?_action=changePassword", realm, username); + UserDTO user = openAMRestClient.put().uri(uri).body(requestBody) + .header(openAMConfig.tokenHeader(), tokenId) + .header("Accept-API-Version","resource=2.0, protocol=1.0") + .retrieve() + .body(UserDTO.class); + return new User(user); + } + + @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 + ) { + + realm = getRealmOrDefault(realm); + + Map userProps = new HashMap<>(); + userProps.put("username", userName); + userProps.put("userpassword", password); + if(familyName != null) { + userProps.put(ATTR_MAP.get("familyName"), familyName); + } + if(givenName != null) { + userProps.put(ATTR_MAP.get("givenName"), givenName); + } + if(name != null) { + userProps.put(ATTR_MAP.get("name"), name); + } + if(mail != null) { + userProps.put(ATTR_MAP.get("mail"), mail); + } + if(phone != null) { + userProps.put(ATTR_MAP.get("phone"), phone); + } + + String tokenId = getTokenId(); + + String uri = String.format("/json/realms/%s/users/?_action=create", realm); + UserDTO user = openAMRestClient.post().uri(uri).body(userProps) + .header(openAMConfig.tokenHeader(), tokenId) + .header("Accept-API-Version","resource=2.0, protocol=1.0") + .retrieve() + .body(UserDTO.class); + return new User(user); + } + + @Tool(name = "delete_user", description = "Deletes a user") + public Map deleteUser(@ToolParam(required = false, description = "If not set, uses root realm") String realm, + @ToolParam(description = "Username (login)") String username) { + + realm = getRealmOrDefault(realm); + + String tokenId = getTokenId(); + + String uri = String.format("/json/realms/%s/users/%s", realm, username); + return openAMRestClient.delete().uri(uri) + .header(openAMConfig.tokenHeader(), tokenId) + .header("Accept-API-Version","resource=2.0, protocol=1.0") + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + } +} diff --git a/openam-mcp-server/src/main/resources/application.yml b/openam-mcp-server/src/main/resources/application.yml new file mode 100644 index 0000000000..7ca35d6b87 --- /dev/null +++ b/openam-mcp-server/src/main/resources/application.yml @@ -0,0 +1,26 @@ +server: + port: 8081 +spring: + application: + name: OpenAM MCP Server + ai: + mcp: + server: + name: openam-mcp-server + version: ^project.version^ + protocol: STATELESS + +openam: + url: ${OPENAM_URL:http://openam.example.org:8080/openam} + tokenHeader: ${OPENAM_TOKEN_HEADER:iPlanetDirectoryPro} + useOAuthForAuthentication: ${OPENAM_USE_OAUTH:false} + username: ${OPENAM_ADMIN_USERNAME:amadmin} + password: ${OPENAM_ADMIN_PASSWORD:ampassword} + oidcAuthChain: ${OPENAM_OIDC_AUTH_CHAIN:oidc} + oidcAuthHeader: ${OPENAM_OIDC_AUTH_HEADER:oidc_id_token} + + +logging: + level: + org: + #springframework: DEBUG \ No newline at end of file diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplicationTests.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplicationTests.java new file mode 100644 index 0000000000..4d8b6f7df9 --- /dev/null +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/OpenAmMcpServerApplicationTests.java @@ -0,0 +1,28 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class OpenAmMcpServerApplicationTests { + @Test + void contextLoads() { + } + +} diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigServiceTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigServiceTest.java new file mode 100644 index 0000000000..29a9dc661e --- /dev/null +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/AuthenticationConfigServiceTest.java @@ -0,0 +1,89 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openidentityplatform.openam.mcp.server.model.AuthChain; +import org.openidentityplatform.openam.mcp.server.model.AuthChainDTO; +import org.openidentityplatform.openam.mcp.server.model.AuthModule; +import org.openidentityplatform.openam.mcp.server.model.AuthModuleDTO; +import org.openidentityplatform.openam.mcp.server.model.AuthModuleSchemaDTO; +import org.openidentityplatform.openam.mcp.server.model.SearchResponseDTO; +import org.springframework.core.ParameterizedTypeReference; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +class AuthenticationConfigServiceTest extends OpenAMServiceTest { + + AuthenticationConfigService authenticationConfigService = null; + + @Override + @BeforeEach + public void setupMocks() { + super.setupMocks(); + authenticationConfigService = spy(new AuthenticationConfigService(restClient, openAMConfig)); + } + + @Test + void getOpenAMAuthChains() throws IOException { + InputStream chainsResponseStream = getClass().getClassLoader().getResourceAsStream("auth/chains-response.json"); + SearchResponseDTO chainsResponse = objectMapper.readValue(chainsResponseStream, new TypeReference<>() {}); + doReturn(chainsResponse.result()).when(authenticationConfigService).getRealmAuthChains(anyString(), anyString()); + + InputStream modulesResponseStream = getClass().getClassLoader().getResourceAsStream("auth/modules-response.json"); + SearchResponseDTO modulesResponse = objectMapper.readValue(modulesResponseStream, new TypeReference<>() {}); + doReturn(modulesResponse.result()).when(authenticationConfigService).getRealmAuthModules(anyString(), anyString()); + when(responseSpec.body(eq(new ParameterizedTypeReference>() {}))).thenReturn(chainsResponse); + List authChains = authenticationConfigService.getOpenAMAuthChains(null); + + assertTrue(authChains.size() > 0); + + } + + @Test + void getAuthModules() throws IOException { + InputStream modulesResponseStream = getClass().getClassLoader().getResourceAsStream("auth/modules-response.json"); + SearchResponseDTO modulesResponse = objectMapper.readValue(modulesResponseStream, new TypeReference<>() {}); + doReturn(modulesResponse.result()).when(authenticationConfigService).getRealmAuthModules(anyString(), anyString()); + + InputStream moduleSchemaStream = getClass().getClassLoader().getResourceAsStream("auth/module-schema-response.json"); + AuthModuleSchemaDTO authModuleSchemaDto = objectMapper.readValue(moduleSchemaStream, new TypeReference<>() {}); + + doReturn(authModuleSchemaDto.properties()).when(authenticationConfigService).getModuleSchema(anyString(), anyString(), anyString()); + + InputStream moduleSettingsStream = getClass().getClassLoader().getResourceAsStream("auth/module-settings-response.json"); + Map moduleSettings = objectMapper.readValue(moduleSettingsStream, new TypeReference<>() {}); + + doReturn(moduleSettings).when(authenticationConfigService).getModuleSettings(anyString(), anyString(), anyString(), anyString()); + + List authModules = authenticationConfigService.getAuthModules(null); + assertTrue(authModules.size() > 0); + + } +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/OpenAMServiceTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/OpenAMServiceTest.java new file mode 100644 index 0000000000..00144a9a2a --- /dev/null +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/OpenAMServiceTest.java @@ -0,0 +1,85 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.client.RestClient; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public abstract class OpenAMServiceTest { + protected final ObjectMapper objectMapper; + + protected final OpenAMConfig openAMConfig; + + public OpenAMServiceTest() { + this.objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + this.openAMConfig = new OpenAMConfig( + null, + false, + null, + null, + "iPlanetDirectoryPro", + null, + null); + } + + + RestClient restClient; + RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + RestClient.RequestBodyUriSpec requestBodyUriSpec; + RestClient.RequestBodySpec requestBodySpec; + RestClient.ResponseSpec responseSpec; + + @BeforeEach + public void setupMocks() { + MockHttpServletRequest mockRequest = new MockHttpServletRequest(); + mockRequest.setAttribute("tokenId", "test-token-id"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest)); + + restClient = mock(RestClient.class); + requestHeadersUriSpec = mock(RestClient.RequestBodyUriSpec.class); + requestBodyUriSpec = mock(RestClient.RequestBodyUriSpec.class); + requestBodySpec = mock(RestClient.RequestBodySpec.class); + responseSpec = mock(RestClient.ResponseSpec.class); + + + when(restClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestBodySpec); + when(requestBodySpec.header(anyString(), anyString())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + + when(restClient.put()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(anyMap())).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.header(anyString(), anyString())).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.retrieve()).thenReturn(responseSpec); + + when(restClient.delete()).thenReturn(requestHeadersUriSpec); + } +} diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/RealmServiceTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/RealmServiceTest.java new file mode 100644 index 0000000000..de8e70c669 --- /dev/null +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/RealmServiceTest.java @@ -0,0 +1,56 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openidentityplatform.openam.mcp.server.model.Realm; +import org.openidentityplatform.openam.mcp.server.model.RealmDTO; +import org.openidentityplatform.openam.mcp.server.model.SearchResponseDTO; +import org.springframework.core.ParameterizedTypeReference; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +class RealmServiceTest extends OpenAMServiceTest { + + RealmService realmService = null; + + @Override + @BeforeEach + public void setupMocks() { + super.setupMocks(); + realmService = new RealmService(restClient, openAMConfig); + } + + @Test + void getRealms() throws IOException { + InputStream is = getClass().getClassLoader().getResourceAsStream("realms/realms-response.json"); + SearchResponseDTO realmsResponse = objectMapper.readValue(is, new TypeReference<>() {}); + + when(responseSpec.body(eq(new ParameterizedTypeReference>() {}))).thenReturn(realmsResponse); + + List realmList = realmService.getRealms(); + assertEquals(realmList.size(), 2); + } +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/UserServiceTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/UserServiceTest.java new file mode 100644 index 0000000000..0ecc19f800 --- /dev/null +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/service/UserServiceTest.java @@ -0,0 +1,89 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2025 3A Systems LLC. + */ + +package org.openidentityplatform.openam.mcp.server.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openidentityplatform.openam.mcp.server.model.SearchResponseDTO; +import org.openidentityplatform.openam.mcp.server.model.User; +import org.openidentityplatform.openam.mcp.server.model.UserDTO; +import org.springframework.core.ParameterizedTypeReference; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +class UserServiceTest extends OpenAMServiceTest { + + UserService userService = null; + + @BeforeEach() + @Override + public void setupMocks() { + super.setupMocks(); + userService = new UserService(restClient, openAMConfig); + + } + + @Test + void getUsersTest() throws Exception { + + InputStream is = getClass().getClassLoader().getResourceAsStream("users/users-list-response.json"); + SearchResponseDTO userSearchResponse = objectMapper.readValue(is, new TypeReference<>() {}); + when(responseSpec.body(eq(new ParameterizedTypeReference>() {}))).thenReturn(userSearchResponse); + + List userList = userService.getUsers(null, null); + assertEquals(userList.size(), 2); + } + + @Test + void setUserAttributeTest() throws IOException { + InputStream is = getClass().getClassLoader().getResourceAsStream("users/user-response.json"); + UserDTO userDTO = objectMapper.readValue(is, UserDTO.class); + + when(responseSpec.body(UserDTO.class)).thenReturn(userDTO); + + User user = userService.setUserAttribute(null, "demo", "familyName", "Flintstone"); + assertNotNull(user); + } + + @Test + void setUserPassword() throws IOException { + InputStream is = getClass().getClassLoader().getResourceAsStream("users/user-response.json"); + UserDTO userDTO = objectMapper.readValue(is, UserDTO.class); + + when(responseSpec.body(UserDTO.class)).thenReturn(userDTO); + + User user = userService.setUserPassword(null, "demo", "passw0rd"); + assertNotNull(user); + } + + @Test + void deleteUser() { + when(responseSpec.body(eq(new ParameterizedTypeReference>() {}))).thenReturn(Map.of("success", "true")); + + Map result = userService.deleteUser(null, "demo"); + assertEquals("true", result.get("success")); + } +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/auth/all-modules-response.json b/openam-mcp-server/src/test/resources/auth/all-modules-response.json new file mode 100644 index 0000000000..40da120960 --- /dev/null +++ b/openam-mcp-server/src/test/resources/auth/all-modules-response.json @@ -0,0 +1,174 @@ +{ + "result": [ + { + "_id": "httpbasic", + "name": "HTTP Basic", + "collection": false + }, + { + "_id": "activedirectory", + "name": "Active Directory", + "collection": false + }, + { + "_id": "adaptiverisk", + "name": "Adaptive Risk ", + "collection": false + }, + { + "_id": "anonymous", + "name": "Anonymous", + "collection": false + }, + { + "_id": "certificate", + "name": "Certificate", + "collection": false + }, + { + "_id": "datastore", + "name": "Data Store", + "collection": false + }, + { + "_id": "persistentcookie", + "name": "Persistent Cookie", + "collection": false + }, + { + "_id": "jdbc", + "name": "JDBC", + "collection": false + }, + { + "_id": "ldap", + "name": "LDAP", + "collection": false + }, + { + "_id": "msisdn", + "name": "MSISDN", + "collection": false + }, + { + "_id": "membership", + "name": "Membership", + "collection": false + }, + { + "_id": "windowsnt", + "name": "Windows NT", + "collection": false + }, + { + "_id": "oauth2", + "name": "OAuth 2.0 / OpenID Connect", + "collection": false + }, + { + "_id": "windowsdesktopsso", + "name": "Windows Desktop SSO", + "collection": false + }, + { + "_id": "openidconnect", + "name": "OpenID Connect id_token bearer", + "collection": false + }, + { + "_id": "radius", + "name": "RADIUS", + "collection": false + }, + { + "_id": "hotp", + "name": "HOTP", + "collection": false + }, + { + "_id": "authenticatoroath", + "name": "Authenticator (OATH)", + "collection": false + }, + { + "_id": "federation", + "name": "Federation", + "collection": false + }, + { + "_id": "sae", + "name": "SAE", + "collection": false + }, + { + "_id": "wssauth", + "name": "sunAMAuthWSSAuthModuleService", + "collection": false + }, + { + "_id": "scripted", + "name": "Scripted Module", + "collection": false + }, + { + "_id": "deviceidmatch", + "name": "Device Id (Match)", + "collection": false + }, + { + "_id": "deviceidsave", + "name": "Device Id (Save)", + "collection": false + }, + { + "_id": "oath", + "name": "OATH", + "collection": false + }, + { + "_id": "authSaml", + "name": "SAML2", + "collection": false + }, + { + "_id": "authPush", + "name": "Authenticator (Push)", + "collection": false + }, + { + "_id": "authPushReg", + "name": "Authenticator (Push) Registration", + "collection": false + }, + { + "_id": "amster", + "name": "ForgeRock Amster", + "collection": false + }, + { + "_id": "recaptcha", + "name": "QR code confirm from other session", + "collection": false + }, + { + "_id": "securid", + "name": "SecurID", + "collection": false + }, + { + "_id": "webauthnreg", + "name": "WebAuthn Registration", + "collection": false + }, + { + "_id": "webauthnauth", + "name": "WebAuthn Authentication", + "collection": false + }, + { + "_id": "sunAMAuthNtlmService", + "name": "NTLM", + "collection": false + } + ] +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/auth/chains-response.json b/openam-mcp-server/src/test/resources/auth/chains-response.json new file mode 100644 index 0000000000..81a2b5683e --- /dev/null +++ b/openam-mcp-server/src/test/resources/auth/chains-response.json @@ -0,0 +1,119 @@ +{ + "result": [ + { + "loginFailureUrl": [ + "" + ], + "authChainConfiguration": [ + { + "module": "DataStore", + "criteria": "REQUIRED", + "options": {} + } + ], + "loginPostProcessClass": [], + "loginSuccessUrl": [ + "" + ], + "_id": "ldapService", + "_type": { + "_id": "EMPTY", + "name": "Authentication Configuration", + "collection": true + } + }, + { + "loginFailureUrl": [ + "" + ], + "authChainConfiguration": [ + { + "module": "Amster", + "criteria": "REQUIRED", + "options": {} + } + ], + "loginPostProcessClass": [], + "loginSuccessUrl": [ + "" + ], + "_id": "amsterService", + "_type": { + "_id": "EMPTY", + "name": "Authentication Configuration", + "collection": true + } + }, + { + "loginFailureUrl": [ + "" + ], + "authChainConfiguration": [ + { + "module": "oath2", + "criteria": "REQUISITE", + "options": {} + } + ], + "loginPostProcessClass": [], + "loginSuccessUrl": [ + "" + ], + "_id": "oauth", + "_type": { + "_id": "EMPTY", + "name": "Authentication Configuration", + "collection": true + } + }, + { + "loginFailureUrl": [ + "" + ], + "authChainConfiguration": [ + { + "module": "oidc", + "criteria": "REQUISITE", + "options": {} + } + ], + "loginPostProcessClass": [], + "loginSuccessUrl": [ + "" + ], + "_id": "oidc", + "_type": { + "_id": "EMPTY", + "name": "Authentication Configuration", + "collection": true + } + }, + { + "loginFailureUrl": [ + "" + ], + "authChainConfiguration": [ + { + "module": "HOTP", + "criteria": "REQUISITE", + "options": {} + } + ], + "loginPostProcessClass": [], + "loginSuccessUrl": [ + "" + ], + "_id": "hotp", + "_type": { + "_id": "EMPTY", + "name": "Authentication Configuration", + "collection": true + } + } + ], + "resultCount": 5, + "pagedResultsCookie": null, + "totalPagedResultsPolicy": "NONE", + "totalPagedResults": -1, + "remainingPagedResults": -1 +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/auth/module-schema-response.json b/openam-mcp-server/src/test/resources/auth/module-schema-response.json new file mode 100644 index 0000000000..e3f489cb25 --- /dev/null +++ b/openam-mcp-server/src/test/resources/auth/module-schema-response.json @@ -0,0 +1,158 @@ +{ + "type": "object", + "properties": { + "userProfileEmailAttribute": { + "title": "Email Attribute Name", + "description": "This is the attribute name used by the OTP to email the user", + "propertyOrder": 1400, + "required": true, + "type": "string", + "exampleValue": "" + }, + "smtpFromAddress": { + "title": "Email From Address", + "description": "Emails from the HOTP Authentication module will come from this address.", + "propertyOrder": 800, + "required": true, + "type": "string", + "exampleValue": "" + }, + "authenticationLevel": { + "title": "Authentication Level", + "description": "The authentication level associated with this module.

Each authentication module has an authentication level that can be used to indicate the level of security associated with the module; 0 is the lowest (and the default).", + "propertyOrder": 100, + "required": true, + "type": "integer", + "exampleValue": "" + }, + "mobileCarrierAttribute": { + "title": "Mobile Carrier Attribute Name", + "description": "This is the attribute name used for a mobile carrier domain for sending SMS messages", + "propertyOrder": 1300, + "required": true, + "type": "string", + "exampleValue": "" + }, + "otpValidityDuration": { + "title": "One Time Password Validity Length", + "description": "This One Time Password will remain valid for this period (in minutes)", + "propertyOrder": 900, + "required": true, + "type": "integer", + "exampleValue": "" + }, + "userProfileTelephoneAttribute": { + "title": "Mobile Phone Number Attribute Name", + "description": "This is the attribute name used for a requested text message", + "propertyOrder": 1200, + "required": true, + "type": "string", + "exampleValue": "" + }, + "autoSendOTP": { + "title": "Auto Send OTP Code", + "description": "Select this checkbox if the OTP should be sent automatically", + "propertyOrder": 1500, + "required": true, + "type": "boolean", + "exampleValue": "" + }, + "smtpUsername": { + "title": "Mail Server Authentication Username", + "description": "The username to use if the mail server is using SMTP authentication", + "propertyOrder": 500, + "required": true, + "type": "string", + "exampleValue": "" + }, + "otpLength": { + "title": "One Time Password Length ", + "description": "The length of the generated One Time Password (in digits)", + "propertyOrder": 1000, + "required": true, + "enum": [ + "6", + "8" + ], + "options": { + "enum_titles": [ + "6", + "8" + ] + }, + "type": "string", + "exampleValue": "" + }, + "smsGatewayClass": { + "title": "SMS Gateway Implementation Class", + "description": "The HOTP authentication module uses this class to send SMS messages.

The SMS gateway class must implement the following interface

com.sun.identity.authentication.modules.hotp.SMSGateway", + "propertyOrder": 200, + "required": true, + "type": "string", + "exampleValue": "" + }, + "otpDeliveryMethod": { + "title": "One Time Password Delivery", + "description": "The mechanism used to deliver the One Time Password", + "propertyOrder": 1100, + "required": true, + "enum": [ + "SMS", + "E-mail", + "SMS and E-mail" + ], + "options": { + "enum_titles": [ + "SMS", + "E-mail", + "SMS and E-mail" + ] + }, + "type": "string", + "exampleValue": "" + }, + "smtpSslEnabled": { + "title": "Mail Server Secure Connection ", + "description": "This setting controls whether the authentication module communicates with the mail server using SSL/TLS", + "propertyOrder": 700, + "required": true, + "enum": [ + "SSL", + "Non SSL" + ], + "options": { + "enum_titles": [ + "SSL", + "Non SSL" + ] + }, + "type": "string", + "exampleValue": "" + }, + "smtpHostname": { + "title": "Mail Server Host Name", + "description": "The name of the mail server; OpenAM will use SMTP to send the messages.", + "propertyOrder": 300, + "required": true, + "type": "string", + "exampleValue": "" + }, + "smtpUserPassword": { + "title": "Mail Server Authentication Password", + "description": "The password to use if the mail server is using SMTP authentication", + "propertyOrder": 600, + "required": true, + "type": "string", + "format": "password", + "exampleValue": "" + }, + "smtpHostPort": { + "title": "Mail Server Host Port", + "description": "The port of the mail server.

The default port for SMTP is 25, if using SSL the default port is 465.", + "propertyOrder": 400, + "required": true, + "type": "integer", + "exampleValue": "" + } + } +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/auth/module-settings-response.json b/openam-mcp-server/src/test/resources/auth/module-settings-response.json new file mode 100644 index 0000000000..e2a842739f --- /dev/null +++ b/openam-mcp-server/src/test/resources/auth/module-settings-response.json @@ -0,0 +1,23 @@ +{ + "smtpHostPort": 465, + "smtpSslEnabled": "SSL", + "autoSendOTP": false, + "userProfileEmailAttribute": "mail", + "smtpUsername": "opensso.sun", + "otpDeliveryMethod": "SMS and E-mail", + "smsGatewayClass": "com.sun.identity.authentication.modules.hotp.DefaultSMSGatewayImpl", + "otpLength": "8", + "smtpHostname": "smtp.gmail.com", + "authenticationLevel": 0, + "mobileCarrierAttribute": "", + "otpValidityDuration": 5, + "userProfileTelephoneAttribute": "telephoneNumber", + "smtpFromAddress": "no-reply@openam.org", + "smtpUserPassword": null, + "_id": "hotp", + "_type": { + "_id": "hotp", + "name": "HOTP", + "collection": true + } +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/auth/modules-response.json b/openam-mcp-server/src/test/resources/auth/modules-response.json new file mode 100644 index 0000000000..fa634b2ee3 --- /dev/null +++ b/openam-mcp-server/src/test/resources/auth/modules-response.json @@ -0,0 +1,59 @@ +{ + "result": [ + { + "_id": "SAE", + "typeDescription": "SAE", + "type": "sae" + }, + { + "_id": "Amster", + "typeDescription": "ForgeRock Amster", + "type": "amster" + }, + { + "_id": "LDAP", + "typeDescription": "LDAP", + "type": "ldap" + }, + { + "_id": "oath2", + "typeDescription": "Authenticator (OATH)", + "type": "authenticatoroath" + }, + { + "_id": "WSSAuthModule", + "typeDescription": "WSSAuth", + "type": "wssauth" + }, + { + "_id": "oidc", + "typeDescription": "OpenID Connect id_token bearer", + "type": "openidconnect" + }, + { + "_id": "OATH", + "typeDescription": "OATH", + "type": "oath" + }, + { + "_id": "DataStore", + "typeDescription": "Data Store", + "type": "datastore" + }, + { + "_id": "HOTP", + "typeDescription": "HOTP", + "type": "hotp" + }, + { + "_id": "Federation", + "typeDescription": "Federation", + "type": "federation" + } + ], + "resultCount": 10, + "pagedResultsCookie": null, + "totalPagedResultsPolicy": "EXACT", + "totalPagedResults": 10, + "remainingPagedResults": -1 +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/realms/realms-response.json b/openam-mcp-server/src/test/resources/realms/realms-response.json new file mode 100644 index 0000000000..d915a43e1d --- /dev/null +++ b/openam-mcp-server/src/test/resources/realms/realms-response.json @@ -0,0 +1,26 @@ +{ + "result": [ + { + "_id": "Lw", + "parentPath": null, + "active": true, + "name": "/", + "aliases": [ + "openam", + "openam.example.org" + ] + }, + { + "_id": "L3Rlc3Q", + "parentPath": "/", + "active": true, + "name": "test", + "aliases": [] + } + ], + "resultCount": 2, + "pagedResultsCookie": null, + "totalPagedResultsPolicy": "NONE", + "totalPagedResults": -1, + "remainingPagedResults": -1 +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/users/user-response.json b/openam-mcp-server/src/test/resources/users/user-response.json new file mode 100644 index 0000000000..cf3eda379e --- /dev/null +++ b/openam-mcp-server/src/test/resources/users/user-response.json @@ -0,0 +1,76 @@ +{ + "username": "demo", + "realm": "dc=openam,dc=forgerock,dc=org", + "uid": [ + "demo" + ], + "createTimestamp": [ + "20160108155628Z" + ], + "inetUserStatus": [ + "Active" + ], + "mail": [ + "demo.user@example.com" + ], + "sn": [ + "demo" + ], + "cn": [ + "demo" + ], + "objectClass": [ + "devicePrintProfilesContainer", + "person", + "sunIdentityServerLibertyPPService", + "sunFederationManagerDataStore", + "inetorgperson", + "oathDeviceProfilesContainer", + "iPlanetPreferences", + "iplanet-am-auth-configuration-service", + "sunFMSAML2NameIdentifier", + "organizationalperson", + "inetuser", + "kbaInfoContainer", + "forgerock-am-dashboard-service", + "iplanet-am-managed-person", + "iplanet-am-user-service", + "sunAMAuthAccountLockout", + "top" + ], + "kbaInfo": [ + { + "questionId": "2", + "answer": { + "$crypto": { + "value": { + "algorithm": "SHA-256", + "data": "VXGtsnjJMC...MQJ/goU5hkfF" + }, + "type": "salted-hash" + } + } + }, + { + "questionId": "1", + "answer": { + "$crypto": { + "value": { + "algorithm": "SHA-256", + "data": "cfYYzi9U...rVfFl0Tdw0iX" + }, + "type": "salted-hash" + } + } + } + ], + "dn": [ + "uid=demo,ou=people,dc=openam,dc=forgerock,dc=org" + ], + "universalid": [ + "id=demo,ou=user,dc=openam,dc=forgerock,dc=org" + ], + "modifyTimestamp": [ + "20160113010610Z" + ] +} \ No newline at end of file diff --git a/openam-mcp-server/src/test/resources/users/users-list-response.json b/openam-mcp-server/src/test/resources/users/users-list-response.json new file mode 100644 index 0000000000..c26d36c7cc --- /dev/null +++ b/openam-mcp-server/src/test/resources/users/users-list-response.json @@ -0,0 +1,119 @@ +{ + "result": [ + { + "username": "amAdmin", + "realm": "dc=openam,dc=forgerock,dc=org", + "sunIdentityMSISDNNumber": [], + "mail": [], + "sn": [ + "amAdmin" + ], + "givenName": [ + "amAdmin" + ], + "universalid": [ + "id=amAdmin,ou=user,dc=openam,dc=forgerock,dc=org" + ], + "cn": [ + "amAdmin" + ], + "iplanet-am-user-success-url": [], + "telephoneNumber": [], + "roles": [ + "ui-global-admin", + "ui-realm-admin" + ], + "iplanet-am-user-failure-url": [], + "inetuserstatus": [ + "Active" + ], + "postalAddress": [], + "dn": [ + "uid=amAdmin,ou=people,dc=openam,dc=forgerock,dc=org" + ], + "employeeNumber": [], + "iplanet-am-user-alias-list": [] + }, + { + "username": "demo", + "realm": "dc=openam,dc=forgerock,dc=org", + "uid": [ + "demo" + ], + "createTimestamp": [ + "20160108155628Z" + ], + "inetUserStatus": [ + "Active" + ], + "mail": [ + "demo.user@example.com" + ], + "sn": [ + "demo" + ], + "cn": [ + "demo" + ], + "objectClass": [ + "devicePrintProfilesContainer", + "person", + "sunIdentityServerLibertyPPService", + "sunFederationManagerDataStore", + "inetorgperson", + "oathDeviceProfilesContainer", + "iPlanetPreferences", + "iplanet-am-auth-configuration-service", + "sunFMSAML2NameIdentifier", + "organizationalperson", + "inetuser", + "kbaInfoContainer", + "forgerock-am-dashboard-service", + "iplanet-am-managed-person", + "iplanet-am-user-service", + "sunAMAuthAccountLockout", + "top" + ], + "kbaInfo": [ + { + "questionId": "2", + "answer": { + "$crypto": { + "value": { + "algorithm": "SHA-256", + "data": "VXGtsnjJMC...MQJ/goU5hkfF" + }, + "type": "salted-hash" + } + } + }, + { + "questionId": "1", + "answer": { + "$crypto": { + "value": { + "algorithm": "SHA-256", + "data": "cfYYzi9U...rVfFl0Tdw0iX" + }, + "type": "salted-hash" + } + } + } + ], + "dn": [ + "uid=demo,ou=people,dc=openam,dc=forgerock,dc=org" + ], + "universalid": [ + "id=demo,ou=user,dc=openam,dc=forgerock,dc=org" + ], + "modifyTimestamp": [ + "20160113010610Z" + ] + } + ], + "resultCount": 2, + "pagedResultsCookie": null, + "totalPagedResultsPolicy": "NONE", + "totalPagedResults": -1, + "remainingPagedResults": -1 +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index d778d57dc4..d9b42154f4 100644 --- a/pom.xml +++ b/pom.xml @@ -249,6 +249,15 @@ + + jdk17.options + + [17,) + + + openam-mcp-server + +