diff --git a/samples/security/apikey/ai-agent/.gitignore b/samples/security/apikey/ai-agent/.gitignore new file mode 100644 index 00000000..667aaef0 --- /dev/null +++ b/samples/security/apikey/ai-agent/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/samples/security/apikey/ai-agent/README.md b/samples/security/apikey/ai-agent/README.md new file mode 100644 index 00000000..d30a3a81 --- /dev/null +++ b/samples/security/apikey/ai-agent/README.md @@ -0,0 +1,133 @@ +# Spring AI Agent + +A comprehensive AI-powered agent built with Spring AI framework, featuring weather forecasting capabilities and secure OAuth integration. + +## Related Documentation + +This project is part of a larger microservices ecosystem: + +- [Weather Service Documentation](../weather/README.md) - Weather forecast service with global coverage + +## Project Overview + +### Description + +The Spring AI Agent is a demonstration of how to build modern AI-powered applications using the Spring AI framework. It provides weather forecasting capabilities through: + +- Weather forecasts for any city worldwide +- Integration with external weather APIs +- Model Context Protocol (MCP) client for connecting to weather services +- Secure OAuth authentication and authorization + +The application serves as the central component in a microservices architecture, connecting to the Weather service through the Model Context Protocol (MCP). + +### Purpose + +This application serves as: + +1. A reference implementation for Spring AI integration with weather services +2. A demonstration of secure AI application patterns with OAuth +3. A practical example of building weather assistants with Spring Boot +4. A showcase for integrating with Amazon Bedrock and weather APIs + +### Technology Stack + +- **Java 21**: Latest LTS version with modern language features +- **Spring Boot 3.5.7**: Core framework for building the application +- **Spring AI 1.0.3**: AI integration framework +- **Spring Security**: OAuth 2.0 authentication and authorization +- **Amazon Bedrock**: AI model provider (Claude Sonnet 4) +- **Docker**: Containerization for application + +## Security + +### OAuth 2.0 Integration + +The application implements OAuth 2.0 for secure authentication and authorization: + +- **Authorization Server**: Integrated OAuth 2.0 authorization server +- **Resource Protection**: Secured API endpoints with JWT tokens +- **Token Validation**: Automatic JWT token validation and user context + +## Getting Started + +### Prerequisites + +- Java 21 or higher +- Maven 3.8 or higher +- AWS account with Amazon Bedrock access + +### Prerequisites for Full Functionality + +Before starting the AI agent, ensure the required services are running: + +1. **Start Authorization Server** (port 9000): + ```bash + cd ../authorization-server/ + mvn spring-boot:run + ``` + +2. **Start Weather Service** (port 8083): + ```bash + cd ../weather/ + mvn spring-boot:run + ``` + +These services provide OAuth authentication and weather forecasting tools that the AI agent uses. + +#### Running the AI Agent + +```bash +cd ai-agent/ +mvn spring-boot:run +``` + +This will: +- Configure secure endpoints for weather data access +- Connect to the weather service via MCP for authenticated users only +- Connect to the authorization server for OAuth authentication +- Start the application on port 8080 + +#### Access Points + +Once all applications are running, you can access: + +- **Main Application**: `http://localhost:8080/` + +### AWS Configuration + +1. Configure AWS credentials: + ```bash + aws configure + ``` + +2. Ensure you have access to Amazon Bedrock and the required models (Claude Sonnet 4). + +### Building and Running the Application + +1. **Standard Build and Run:** + ```bash + cd ai-agent/ + mvn clean package + mvn spring-boot:run + ``` + +2. The application will be available at: + ``` + http://localhost:8080/ + ``` + +### Authentication Flow + +1. Navigate to `http://localhost:9000/` (authorization server) +2. Authenticate with your credentials +3. Use the authorization code to obtain an access token +4. Access weather endpoints with the Bearer token + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/samples/security/apikey/ai-agent/mvnw b/samples/security/apikey/ai-agent/mvnw new file mode 100755 index 00000000..19529ddf --- /dev/null +++ b/samples/security/apikey/ai-agent/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/samples/security/apikey/ai-agent/mvnw.cmd b/samples/security/apikey/ai-agent/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/samples/security/apikey/ai-agent/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/samples/security/apikey/ai-agent/pom.xml b/samples/security/apikey/ai-agent/pom.xml new file mode 100644 index 00000000..70f95a2f --- /dev/null +++ b/samples/security/apikey/ai-agent/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + com.example + ai-agent-secured-apikey + 0.0.1-SNAPSHOT + ai-agent-secured-apikey + Travel and Expenses AI Agent, secured with ApiKey + + + + + + + + + + + + + + + 21 + 1.1.0-SNAPSHOT + 0.0.3 + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + true + + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.ai + spring-ai-starter-model-bedrock-converse + + + + + org.springframework.ai + spring-ai-starter-mcp-client-webflux + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/samples/security/apikey/ai-agent/samples/invoice-hotel-de.png b/samples/security/apikey/ai-agent/samples/invoice-hotel-de.png new file mode 100644 index 00000000..2f9f5892 Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/invoice-hotel-de.png differ diff --git a/samples/security/apikey/ai-agent/samples/invoice-hotel-sl.png b/samples/security/apikey/ai-agent/samples/invoice-hotel-sl.png new file mode 100644 index 00000000..f896ccaa Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/invoice-hotel-sl.png differ diff --git a/samples/security/apikey/ai-agent/samples/receipt-meals.png b/samples/security/apikey/ai-agent/samples/receipt-meals.png new file mode 100644 index 00000000..4e541db7 Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/receipt-meals.png differ diff --git a/samples/security/apikey/ai-agent/samples/receipt-taxi.png b/samples/security/apikey/ai-agent/samples/receipt-taxi.png new file mode 100644 index 00000000..f659c6a0 Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/receipt-taxi.png differ diff --git a/samples/security/apikey/ai-agent/samples/reinvent.png b/samples/security/apikey/ai-agent/samples/reinvent.png new file mode 100644 index 00000000..f7d7f756 Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/reinvent.png differ diff --git a/samples/security/apikey/ai-agent/samples/ticket-train-at.png b/samples/security/apikey/ai-agent/samples/ticket-train-at.png new file mode 100644 index 00000000..d728f853 Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/ticket-train-at.png differ diff --git a/samples/security/apikey/ai-agent/samples/ticket-tram-cz.png b/samples/security/apikey/ai-agent/samples/ticket-tram-cz.png new file mode 100644 index 00000000..874a712f Binary files /dev/null and b/samples/security/apikey/ai-agent/samples/ticket-tram-cz.png differ diff --git a/samples/security/apikey/ai-agent/samples/travel_and_expenses_policy.md b/samples/security/apikey/ai-agent/samples/travel_and_expenses_policy.md new file mode 100644 index 00000000..8ec58134 --- /dev/null +++ b/samples/security/apikey/ai-agent/samples/travel_and_expenses_policy.md @@ -0,0 +1,138 @@ +# Travel and Expense Policy + +**Effective Date:** [Date] +**Version:** 1.0 +**Approved by:** [Executive Leadership] + +## 1. Purpose and Scope + +This policy establishes guidelines and requirements for business travel and expense reimbursement to ensure compliance with company standards, regulatory requirements, and fiscal responsibility. All employees, contractors, and authorized personnel must adhere to these guidelines when incurring business-related expenses. + +## 2. General Principles + +### 2.1 Business Necessity +All travel and expenses must be: +- Directly related to legitimate business purposes +- Pre-approved by appropriate management +- Reasonable, necessary, and cost-effective +- Properly documented with original receipts + +### 2.2 Expense Documentation Requirements +All expense claims must include: +- Original receipts or invoices +- Clear business justification +- Accurate categorization of expense type +- Compliance verification with policy limits + +## 3. Accommodation Guidelines + +### 3.1 Regional Limits +**Europe:** Maximum accommodation rate of **€130 per night** +- Rates exceeding this limit require Director approval +- Extended stay discounts should be pursued when applicable +- Shared accommodation may be required for cost optimization + +### 3.2 Booking Requirements +- Use company-preferred vendors when available +- Book reasonable accommodations appropriate to business needs +- Avoid luxury or premium-tier hotels unless business-justified + +## 4. Meal and Entertainment Expenses + +### 4.1 Alcoholic Beverages +**All expenses including alcoholic beverages require Manager approval** +- Must be business-related (client entertainment, team events) +- Excessive alcohol consumption is prohibited +- Personal alcohol consumption is not reimbursable + +### 4.2 Meal Limits +- Reasonable meal expenses during business travel +- Client entertainment must include business justification +- Group meals require attendee list and business purpose + +## 5. Expense Categories and Approval Matrix + +### 5.1 Standard Expenses, no Approval needed +- Transportation (economy class) +- Standard accommodation within limits +- Business meals without alcohol + +### 5.2 Standard Expenses (Manager Approval) +- Any expenses including alcoholic beverages +- Office supplies and materials + +### 5.3 Director Approval Required +- Accommodation exceeding regional limits +- Entertainment expenses over €100 +- Non-standard transportation (business class, car rentals) + +### 5.4 Executive Approval Required +- International travel over €1,000 +- Conference and training expenses over €1,500 +- Any expense over €500 per item + +## 6. Document Analysis and Verification + +### 6.1 Required Information +All expense documents must clearly show: +- **Document Type:** Receipt, invoice, ticket, or bill +- **Expense Category:** Meals, accommodation, transportation, etc. +- **Total Amount:** Numerical value only +- **Currency:** Standard currency code (EUR, USD, GBP, etc.) +- **Transaction Date:** In YYYY-MM-DD format + +### 6.2 Additional Requirements by Category + +**For Hotel/Accommodation:** +- Check-in and check-out dates +- Number of nights stayed +- Rate per night verification +- Compliance with regional limits + +**For Meals:** +- Clear indication if alcoholic beverages included +- Business justification for alcohol-inclusive meals +- Required approvals obtained + +## 7. Compliance and Enforcement + +### 7.1 Policy Violations +- Expenses exceeding limits without approval will be rejected +- Alcohol-inclusive expenses without Director approval will be denied +- Falsification of expense reports may result in disciplinary action + +### 7.2 Audit and Review +- Random expense audits will be conducted quarterly +- Non-compliant expenses will be recovered from employee +- Repeated violations may result in travel privilege suspension + +## 8. Approval Workflow + +### 8.1 Standard Process +1. Employee submits expense with required documentation +2. System validates against policy limits +3. Automatic routing to appropriate approval level +4. Approval/rejection notification to employee + +### 8.2 Exception Handling +- Pre-approval required for policy exceptions +- Business justification must be documented +- Alternative solutions must be considered + +## 9. Reimbursement Timeline + +- **Standard Expenses:** 5-7 business days after approval +- **Complex Expenses:** 10-14 business days after approval +- **Policy Violations:** Held pending resolution + +## 10. Contact Information + +**For Policy Questions:** [HR Contact] +**For System Issues:** [IT Support] +**For Approval Escalations:** [Finance Team] + +--- + +**Acknowledgment:** By submitting expense reports, employees acknowledge they have read, understood, and agree to comply with this Travel and Expense Policy. + +**Review Schedule:** This policy will be reviewed annually and updated as necessary to reflect business needs and regulatory changes. diff --git a/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/AiAgentApplication.java b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/AiAgentApplication.java new file mode 100644 index 00000000..18e7fb37 --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/AiAgentApplication.java @@ -0,0 +1,13 @@ +package com.example.ai.agent; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AiAgentApplication { + + public static void main(String[] args) { + SpringApplication.run(AiAgentApplication.class, args); + } + +} diff --git a/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/WebClientConfiguration.java b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/WebClientConfiguration.java new file mode 100644 index 00000000..2a099d31 --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/WebClientConfiguration.java @@ -0,0 +1,20 @@ +package com.example.ai.agent; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +class WebClientConfiguration { + + @Bean + WebClient.Builder apiKeyInjectingWebClientBuilder(@Value("${agent.api.key}") String apiKey) { +// return WebClient.builder().apply(builder -> builder +// .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + "eyJraWQiOiJ0ZTA3MVdlRjV1MWNuekhqUVJuenh5MXRubFRERHJjQ3lwdmtPT0ppUGlrPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiI5NDU4YzQzOC02MDAxLTcwZmUtZTg2MS1jZTM1NDYwYzUyZTMiLCJjdXN0b206dGVuYW50VGllciI6ImJhc2ljIiwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tXC91cy1lYXN0LTFfMHF4TlZvODMzIiwidmVyc2lvbiI6MiwiY2xpZW50X2lkIjoiNjdyajliMzU4dTNxZXA1czNwM2UxdDZtMGciLCJvcmlnaW5fanRpIjoiNjUwYTI1MjMtNTBkNy00N2ZhLWFmNGUtNzllZjlkOWQ1ZGJmIiwiY3VzdG9tOnRlbmFudElkIjoiVVNFUl9NRVRYMTROSF9BQkxBSVIiLCJldmVudF9pZCI6IjJlYmI2YTJlLTY2NjUtNDcxZS1hOGUyLTJkMDU0NzZjMjM1NSIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJhdXRoX3RpbWUiOjE3NTYzOTIzMDEsImV4cCI6MTc1NjM5NTkwMSwiaWF0IjoxNzU2MzkyMzAxLCJqdGkiOiIzMjA0ODdmMC1lNzJhLTRkYTMtODZmYS1jMTFjOWM3YzdiYTkiLCJ1c2VybmFtZSI6Im1jcC11c2VyMSJ9.R-lnzge2UIEtzf-QXOgxfnfACB5M13_njsHwnrhC7naqGIeM_cMwgahb5NMB8QUJKHWk5I9HvIZ3_ZfPG84nhKpZVSYO_RqrChvFJbsa4-3wiHGGfhT6Y4RhjNHBzHIz22o3IubiZv5SPxa-OncTJpJLCR8HOJGCOUJhJ5tdrcnCoh7hzcswRHNNU4mF6nwbPLekSiMWKG1chx5I6lh6XBQPPdrjPt-pU2eSDY5aC75WOkN-0JnMCRoaao3I2oBnjghpEThTKXtyrLMaeNzhPrSF5FwE5LHQMYwW5fTXK8iuq2OD_0a4LDLPlP2kWnJKxnnl_TSRrDsCg-POmn0l4w") +// ); + return WebClient.builder().apply(builder -> builder + .defaultHeader("X-API-KEY", apiKey) + ); + } +} diff --git a/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/controller/ChatController.java b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/controller/ChatController.java new file mode 100644 index 00000000..c3f22b6e --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/controller/ChatController.java @@ -0,0 +1,53 @@ +package com.example.ai.agent.controller; + +import com.example.ai.agent.service.ChatService; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("api") +public class ChatController { + + private final ChatService chatService; + + public ChatController(ChatService chatService) { + this.chatService = chatService; + } + + @PostMapping("chat") + public String chat(@RequestBody ChatRequest request) { + return chatService.processChat(request.prompt()); + } + + @GetMapping("gui") + public String chat() { + String prompt = "Please give me all available hotels in Paris, Checkin 10.10.2025, checkout 15.10.2025"; + String chatResponse = chatService.processChat(prompt); + + String currentHotel = """ +

Available hotels %s

+

%s

+
+ +
+ """.formatted(prompt, chatResponse); + + return """ +

Demo controller

+ %s + +
+ +

Ask for the weather

+

In which city would you like to see the weather?

+
+ + +
+ +
+ """.formatted(currentHotel); + } + + + public record ChatRequest(String prompt) {} +} diff --git a/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/controller/WebViewController.java b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/controller/WebViewController.java new file mode 100644 index 00000000..0dcef751 --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/controller/WebViewController.java @@ -0,0 +1,12 @@ +package com.example.ai.agent.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class WebViewController { + @GetMapping("/") + public String index() { + return "chat-working"; + } +} diff --git a/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/service/ChatService.java b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/service/ChatService.java new file mode 100644 index 00000000..d45cdad0 --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/service/ChatService.java @@ -0,0 +1,83 @@ +package com.example.ai.agent.service; + +import com.example.ai.agent.tool.DateTimeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.stereotype.Service; + +@Service +public class ChatService { + private static final Logger logger = LoggerFactory.getLogger(ChatService.class); + + public static final String SYSTEM_PROMPT = """ + You are a helpful and honest AI Agent for our company. + You can help with questions related to travel and expenses. + + Follow these guidelines strictly: + 1. ACCURACY FIRST: Only provide information you are confident about based on your training data. + 2. ADMIT UNCERTAINTY: If you are unsure about any fact, detail, or answer, respond with "I don't know" or "I'm not certain about that." + 3. NO SPECULATION: Do not guess, speculate, or make up information. It's better to say "I don't know" than to provide potentially incorrect information. + 4. PARTIAL KNOWLEDGE: If you know some aspects of a topic but not others, clearly state what you know and what you don't know. + 5. SOURCES: Do not claim to have access to real-time information, current events after your training cutoff, or specific databases unless explicitly provided. + 6. TABLE FORMAT: Always use clean markdown tables for structured data presentation. + + Example responses: + - "I don't know the current stock price of that company." + - "I'm not certain about the specific details of that recent event." + - "I don't have enough information to answer that question accurately." + Remember: Being honest about limitations builds trust. Always choose "I don't know" over potentially incorrect information. + """; + + private final ChatClient chatClient; + + public ChatService(DateTimeService dateTimeService, + ToolCallbackProvider tools, + ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder + .defaultSystem(SYSTEM_PROMPT) + .defaultTools(dateTimeService) + .defaultToolCallbacks(tools) + .build(); + + logger.info("ChatService initialized with embedded ChatClient"); + } + + public String processChat(String prompt) { + logger.info("Processing text chat request - prompt: '{}'", prompt); + try { + var chatResponse = chatClient + .prompt().user(prompt) + .call() + .chatResponse(); + return extractTextFromResponse(chatResponse); + } catch (RuntimeException e) { + logger.error("Error processing chat request", e); + throw e; + } + } + + public String extractTextFromResponse(org.springframework.ai.chat.model.ChatResponse chatResponse) { + if (chatResponse != null) { + // First try the standard approach + String text = chatResponse.getResult().getOutput().getText(); + if (text != null && !text.isEmpty()) { + return text; + } + + // Fallback: iterate through generations for models with reasoning content + if (!chatResponse.getResults().isEmpty()) { + for (var generation : chatResponse.getResults()) { + String textContent = generation.getOutput().getText(); + if (textContent != null && !textContent.isEmpty()) { + logger.info("Found text content: '{}'", textContent); + return textContent; + } + } + } + } + + return "I don't know - no response received."; + } +} diff --git a/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/tool/DateTimeService.java b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/tool/DateTimeService.java new file mode 100644 index 00000000..3b7373e7 --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/java/com/example/ai/agent/tool/DateTimeService.java @@ -0,0 +1,23 @@ +package com.example.ai.agent.tool; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Service; + +import java.time.format.DateTimeFormatter; + +@Service +public class DateTimeService { + + @Tool(description = """ + Get the current date and time in the specified timezone. + Requires: timeZone - A valid timezone ID (e.g., 'UTC', 'America/New_York', 'Europe/London'). + Returns: The current date and time in ISO format (YYYY-MM-DDTHH:MM:SS). + Errors: ILLEGAL_ARGUMENT if the timezone ID is invalid. + Note: For future dates, use getCurrentDateTime and calculate the future date based on the current date. + """) + public String getCurrentDateTime(String timeZone) { + return java.time.ZonedDateTime.now(java.time.ZoneId.of(timeZone)) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + +} diff --git a/samples/security/apikey/ai-agent/src/main/resources/application.properties b/samples/security/apikey/ai-agent/src/main/resources/application.properties new file mode 100644 index 00000000..132f21bc --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/resources/application.properties @@ -0,0 +1,36 @@ + +# Simplified logging pattern - only show the message +logging.pattern.console=%msg%n + +# Debugging +logging.level.org.springframework.ai=DEBUG +spring.ai.chat.observations.log-completion=true +spring.ai.chat.observations.include-error-logging=true +spring.ai.tools.observations.include-content=true +# spring.ai.chat.observations.log-prompt=true +# spring.ai.vectorstore.observations.log-query-response=true + +# Thymeleaf Configuration +spring.thymeleaf.cache=false +spring.thymeleaf.prefix=classpath:/templates/ +spring.thymeleaf.suffix=.html + +# Amazon Bedrock Configuration +spring.ai.bedrock.aws.region=us-east-1 +#spring.ai.bedrock.converse.chat.options.max-tokens=10000 +#spring.ai.bedrock.converse.chat.options.model=openai.gpt-oss-120b-1:0 +spring.ai.bedrock.converse.chat.options.model=global.anthropic.claude-sonnet-4-20250514-v1:0 +#spring.ai.bedrock.converse.chat.options.model=us.amazon.nova-pro-v1:0 +# spring.ai.bedrock.converse.chat.options.model=us.amazon.nova-premier-v1:0 + +# MCP Client Configuration +spring.ai.mcp.client.toolcallback.enabled=true +# via env var SPRING_AI_MCP_CLIENT_SSE_CONNECTIONS_SERVER1_URL=http://localhost:8081 +spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:8081 +#spring.ai.mcp.client.sse.connections.server2.url=http://localhost:8082 +#spring.ai.mcp.client.sse.connections.server3.url=http://localhost:8083 + +agent.api.key=DEFINED_IN_VARIABLE + +logging.level.org.springframework.security=TRACE + diff --git a/samples/security/apikey/ai-agent/src/main/resources/templates/chat-working.html b/samples/security/apikey/ai-agent/src/main/resources/templates/chat-working.html new file mode 100644 index 00000000..10be7942 --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/resources/templates/chat-working.html @@ -0,0 +1,585 @@ + + + + + + AI Agent + + + + + + +
+ +
+
+
+

🤖 AI Agent

+
+ +
+

Chat with our AI Agent to help you with your questions!

+
+ + +
+
+
+
+ 🤖 +
+
+

Welcome to the AI Agent! How can I help you today?

+
+
+ +
+ + +
+
+ + + + +
+
+
+
+ + +
+ × + +
+ + + + \ No newline at end of file diff --git a/samples/security/apikey/ai-agent/src/main/resources/templates/chat.html b/samples/security/apikey/ai-agent/src/main/resources/templates/chat.html new file mode 100644 index 00000000..b0e2087e --- /dev/null +++ b/samples/security/apikey/ai-agent/src/main/resources/templates/chat.html @@ -0,0 +1,585 @@ + + + + + + AI Agent + + + + + + +
+ +
+
+
+

🤖 AI Agent

+
+ +
+

Chat with our AI Agent to help you with your questions!

+
+ + +
+
+
+
+ 🤖 +
+
+

Welcome to the AI Agent! How can I help you today?

+
+
+ +
+ + +
+
+ + + + +
+
+
+
+ + +
+ × + +
+ + + + \ No newline at end of file diff --git a/samples/security/apikey/weather/.gitignore b/samples/security/apikey/weather/.gitignore new file mode 100644 index 00000000..420ef838 --- /dev/null +++ b/samples/security/apikey/weather/.gitignore @@ -0,0 +1,36 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### macOS ### +.DS_Store \ No newline at end of file diff --git a/samples/security/apikey/weather/README.md b/samples/security/apikey/weather/README.md new file mode 100644 index 00000000..f61ac0b5 --- /dev/null +++ b/samples/security/apikey/weather/README.md @@ -0,0 +1,226 @@ +# Spring AI Weather Service + +A dedicated weather forecast microservice built with Spring Boot and Spring AI, providing weather information through REST API and Model Context Protocol (MCP) integration. + +## Project Overview + +### Description + +The Spring AI Weather Service is a specialized microservice that provides weather forecast capabilities for the AI agent ecosystem. It offers: + +- Weather forecasts for any city worldwide +- Historical and future weather data +- Integration with external weather APIs +- MCP server for AI agent integration + +The service follows Domain-Driven Design (DDD) principles and showcases best practices for building Spring Boot microservices with AI capabilities. + +### Purpose + +This service serves as: + +1. A dedicated weather data provider for the AI agent ecosystem +2. A demonstration of microservice architecture with Spring AI +3. An example of external API integration and caching strategies +4. A showcase for MCP server implementation + +### Technology Stack + +- **Java 21**: Latest LTS version with modern language features +- **Spring Boot 3.5.7**: Core framework for building the application +- **Spring AI 1.0.3**: AI integration with Model Context Protocol (MCP) +- **Spring WebFlux**: Reactive programming for external API calls +- **Open-Meteo API**: External weather data provider +- **Docker**: Containerization for application deployment + +## Quick Start + +```bash +# Clone and navigate to the project +cd weather/ + +# Run the service +mvn spring-boot:run + +# Application available at http://localhost:8081 +``` + +## Getting Started + +### Prerequisites + +- Java 21 or higher +- Maven 3.8 or higher +- Internet connection for external weather API calls + +### Running the Application + +#### Development Mode + +```bash +cd weather/ +mvn spring-boot:run +``` + +This will start the weather service on http://localhost:8081 + +#### Building and Testing + +1. Build the application: + ```bash + cd weather/ + mvn clean package + ``` + +2. Run tests: + ```bash + mvn test + ``` + +### Testing the API + +Test the weather API endpoints: + +```bash +# Get weather forecast for a city +curl "http://localhost:8081/api/weather?city=London&date=2025-11-01" + +# Get weather for different cities +curl "http://localhost:8081/api/weather?city=New York&date=2025-11-01" +curl "http://localhost:8081/api/weather?city=Tokyo&date=2025-11-01" +``` + +## Architecture + +The weather service follows a clean architecture with clear separation of concerns: + +### Domain Model + +The service is organized around the Weather domain with: + +1. **Weather Forecasting**: Core business logic for weather data retrieval +2. **Location Services**: Geocoding and city resolution +3. **External Integration**: Weather API client abstraction + +### Layered Architecture + +1. **Controller Layer**: REST API endpoints +2. **Service Layer**: Business logic and external API coordination +3. **Client Layer**: External API integration with proper error handling +4. **Tools Layer**: AI-specific functionality exposed via MCP + +### AI Integration + +The service integrates with AI systems through: + +1. **MCP Server**: Exposes weather functionality as AI tools +2. **Tool Annotations**: Marks methods for AI consumption +3. **Parameter Validation**: Ensures proper input handling +4. **Response Formatting**: Structures responses for AI consumption + +## API Documentation + +### Weather API + +#### Get Weather Forecast +``` +GET /api/weather?city={city}&date={date} +``` +Parameters: +- `city`: Name of the city for weather forecast +- `date`: Date for the forecast in format yyyy-MM-dd (can be past, present, or future) + +Response: +``` +Weather for London, GB on 2025-11-01: +Min: 8.2 deg C, Max: 14.7 deg C +``` + +## AI Tools + +The service exposes the following AI tools through the MCP server: + +### Weather Tools +- `getWeatherForecast`: Get weather forecast for a city on a specific date + +### Security + +The AI tools methods are secured and require proper authentication to access the weather forecasting capabilities. + +## Best Practices + +The service demonstrates several best practices: + +### Microservice Design +- Single responsibility principle +- Clear API boundaries +- Proper error handling +- External dependency abstraction + +### API Design +- RESTful endpoints +- Consistent error responses +- Proper HTTP status codes +- Input validation + +### External Integration +- Resilient API client design +- Timeout handling +- Proper error propagation +- Rate limiting considerations + +### Testing +- Unit tests for business logic +- Integration tests for API endpoints +- Mock external dependencies +- Comprehensive error scenario testing + +## Configuration + +### Application Properties + +The service can be configured through `application.properties`: + +```properties +# Server configuration +server.port=8083 + +# Logging configuration +logging.level.com.example.weather=INFO +logging.level.org.springframework.web.reactive.function.client=DEBUG + +# External API timeouts +weather.api.timeout=15s +``` + +## Integration with AI Agent + +The weather service integrates with the AI agent through MCP: + +1. **Start Weather Service** (port 8081): + ```bash + cd weather/ + mvn spring-boot:run + ``` + +2. **Configure AI Agent**: Update the AI agent's MCP configuration to include the weather service endpoint + +3. **Use Weather Tools**: The AI agent can now access weather forecasting capabilities through the exposed tools + +## Future Enhancements + +- Add weather alerts and notifications +- Implement caching for frequently requested locations +- Add more detailed weather information (humidity, wind, precipitation) +- Integrate with multiple weather data providers +- Add weather history and trends analysis +- Implement rate limiting and API key management +- Add monitoring and observability features + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/samples/security/apikey/weather/mvnw b/samples/security/apikey/weather/mvnw new file mode 100755 index 00000000..19529ddf --- /dev/null +++ b/samples/security/apikey/weather/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/samples/security/apikey/weather/mvnw.cmd b/samples/security/apikey/weather/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/samples/security/apikey/weather/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/samples/security/apikey/weather/pom.xml b/samples/security/apikey/weather/pom.xml new file mode 100644 index 00000000..cb867104 --- /dev/null +++ b/samples/security/apikey/weather/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + com.example + weather-secured-apikey + 0.0.1-SNAPSHOT + weather-secured-apikey + Weather Forecast Service secured with ApiKey + + + + + + + + + + + + + + + 21 + 1.1.0-SNAPSHOT + 0.0.3 + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + + io.modelcontextprotocol.sdk + mcp + 0.15.0-SNAPSHOT + + + org.springaicommunity + mcp-annotations + 0.6.0-SNAPSHOT + + + org.springaicommunity + mcp-server-security + ${mcp-security.version} + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + io.netty + netty-resolver-dns-native-macos + osx-aarch_64 + runtime + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/CustomApiKeyEntity.java b/samples/security/apikey/weather/src/main/java/com/example/weather/CustomApiKeyEntity.java new file mode 100644 index 00000000..2cd37d8a --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/CustomApiKeyEntity.java @@ -0,0 +1,42 @@ +package com.example.weather; + +import org.springaicommunity.mcp.security.server.apikey.ApiKeyEntity; +import org.springaicommunity.mcp.security.server.apikey.memory.ApiKeyEntityImpl; + +public class CustomApiKeyEntity implements ApiKeyEntity { + + private final ApiKeyEntityImpl delegate; + + public static CustomApiKeyEntity create(String id, String name, String secret) { + ApiKeyEntityImpl base = ApiKeyEntityImpl.builder() + .id(id) + .name(name) + .secret(secret) + .build(); + return new CustomApiKeyEntity(base); + } + + private CustomApiKeyEntity(ApiKeyEntityImpl delegate) { + this.delegate = delegate; + } + + @Override + public String getId() { + return delegate.getId(); + } + + @Override + public String getSecret() { + return delegate.getSecret(); + } + + @Override + public void eraseCredentials() { + // Intentionally do nothing - ignore credential erasure + } + + @Override + public ApiKeyEntity copy() { + return new CustomApiKeyEntity(delegate.copy()); + } +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/McpSecurityConfig.java b/samples/security/apikey/weather/src/main/java/com/example/weather/McpSecurityConfig.java new file mode 100644 index 00000000..31b53810 --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/McpSecurityConfig.java @@ -0,0 +1,59 @@ +package com.example.weather; + +import org.jspecify.annotations.NonNull; +import org.springaicommunity.mcp.security.server.apikey.ApiKeyEntity; +import org.springaicommunity.mcp.security.server.apikey.ApiKeyEntityRepository; +import org.springaicommunity.mcp.security.server.apikey.memory.InMemoryApiKeyEntityRepository; +import org.springaicommunity.mcp.security.server.config.McpApiKeyConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +class McpSecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests(authz -> authz.anyRequest().authenticated()) + .with(McpApiKeyConfigurer.mcpServerApiKey(), (apiKey) -> apiKey.apiKeyRepository(buildApiKeyRepository())) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(CsrfConfigurer::disable) + .build(); + } + + private ApiKeyEntityRepository<@NonNull ApiKeyEntity> buildApiKeyRepository() { +// ApiKeyEntity apiKey = ApiKeyEntityImpl.builder() +// .name("test api key") +// .id("api01") +// // "mycustomapikey +// .secret("mycustomapikey") +// .build(); + + // Spring erases credentials after the first call by default, CustomApiKeyEntity ignores erasing + var apiKey = CustomApiKeyEntity.create("api01", "test api key", "mycustomapikey"); + return new InMemoryApiKeyEntityRepository<>(List.of(apiKey)); + } + + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("http://localhost:*")); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApiClient.java b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApiClient.java new file mode 100644 index 00000000..0ac9a8ee --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApiClient.java @@ -0,0 +1,28 @@ +package com.example.weather; + +import java.util.Map; + +/** + * Interface for weather API client to abstract external API calls + * for better testability and decoupling. + */ +public interface WeatherApiClient { + + /** + * Get geocoding data for a city + * + * @param city The city name to get coordinates for + * @return Map containing geocoding response data + */ + Map getGeocodingData(String city); + + /** + * Get weather data for specific coordinates and date + * + * @param latitude The latitude coordinate + * @param longitude The longitude coordinate + * @param date The date in YYYY-MM-DD format + * @return Map containing weather response data + */ + Map getWeatherData(double latitude, double longitude, String date); +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApiClientImpl.java b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApiClientImpl.java new file mode 100644 index 00000000..9d36604d --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApiClientImpl.java @@ -0,0 +1,70 @@ +package com.example.weather; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.server.ResponseStatusException; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +/** + * Implementation of WeatherApiClient that makes actual HTTP calls to external weather APIs. + */ +@Component +public class WeatherApiClientImpl implements WeatherApiClient { + private static final Logger logger = LoggerFactory.getLogger(WeatherApiClientImpl.class); + private final WebClient webClient; + + public WeatherApiClientImpl(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.build(); + } + + @Override + public Map getGeocodingData(String city) { + String encodedCity = URLEncoder.encode(city, StandardCharsets.UTF_8); + String url = "https://geocoding-api.open-meteo.com/v1/search?name=" + encodedCity + "&count=1"; + + logger.debug("Calling geocoding API: {}", url); + + try { + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(15)) + .block(); + } catch (Exception e) { + logger.warn("Error calling geocoding service: {}", e.getMessage()); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Error connecting to geocoding service: " + e.getMessage()); + } + } + + @Override + public Map getWeatherData(double latitude, double longitude, String date) { + String url = String.format( + "https://api.open-meteo.com/v1/forecast?latitude=%s&longitude=%s&daily=temperature_2m_max,temperature_2m_min&timezone=auto&start_date=%s&end_date=%s", + latitude, longitude, date, date + ); + + logger.debug("Calling weather API: {}", url); + + try { + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(15)) + .block(); + } catch (Exception e) { + logger.warn("Error calling weather service: {}", e.getMessage()); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Error connecting to weather service: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApplication.java b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApplication.java new file mode 100644 index 00000000..393eff77 --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherApplication.java @@ -0,0 +1,13 @@ +package com.example.weather; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WeatherApplication { + + public static void main(String[] args) { + SpringApplication.run(WeatherApplication.class, args); + } + +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherController.java b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherController.java new file mode 100644 index 00000000..ffd2d242 --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherController.java @@ -0,0 +1,24 @@ +package com.example.weather; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("api/weather") +public class WeatherController { + private final WeatherService weatherService; + private static final Logger logger = LoggerFactory.getLogger(WeatherController.class); + + public WeatherController(WeatherService weatherService) { + this.weatherService = weatherService; + } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public String getWeather(@RequestParam String city, @RequestParam String date) { + logger.info("Getting weather for city: {} on date: {}", city, date); + return weatherService.getWeather(city, date); + } +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherService.java b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherService.java new file mode 100644 index 00000000..4050202c --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherService.java @@ -0,0 +1,113 @@ +package com.example.weather; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +public class WeatherService { + private static final Logger logger = LoggerFactory.getLogger(WeatherService.class); + private final WeatherApiClient apiClient; + + public WeatherService(WeatherApiClient apiClient) { + this.apiClient = apiClient; + } + + /** + * Get weather forecast for a city on a specific date + * + * @param city The city name + * @param date The date in YYYY-MM-DD format + * @return Weather forecast with min/max temperatures + */ + public String getWeather(String city, String date) { + if (city == null || city.trim().isEmpty()) { + logger.warn("Weather request failed: city parameter is missing"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "City parameter is required"); + } + + try { + LocalDate.parse(date); + } catch (Exception e) { + logger.warn("Weather request failed: invalid date format: {}", date); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD"); + } + + String cleanCity = city.trim(); + logger.debug("Fetching geocoding data for city: {}", cleanCity); + + // Get city coordinates using the API client + Map geocodingResponse = apiClient.getGeocodingData(cleanCity); + + // Extract city data + List results = Collections.emptyList(); + if (geocodingResponse != null && geocodingResponse.containsKey("results")) { + Object resultsObj = geocodingResponse.get("results"); + if (resultsObj instanceof List) { + results = (List) resultsObj; + } + } + + if (results.isEmpty()) { + logger.warn("Weather request failed: city not found: {}", cleanCity); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "City not found: " + cleanCity); + } + + var location = (Map) results.getFirst(); + var latitude = ((Number) location.get("latitude")).doubleValue(); + var longitude = ((Number) location.get("longitude")).doubleValue(); + var cityName = (String) location.get("name"); + var country = location.get("country") != null ? (String) location.get("country") : ""; + + logger.debug("Found location: {}, {}, coordinates: {}, {}", + cityName, country, latitude, longitude); + + // Get weather data using the API client + Map weatherResponse = apiClient.getWeatherData(latitude, longitude, date); + + if (weatherResponse == null) { + logger.warn("Weather request failed: no response from weather service"); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "No response from weather service"); + } + + // Extract weather data + var dailyData = (Map) weatherResponse.get("daily"); + var dailyUnits = (Map) weatherResponse.get("daily_units"); + + if (dailyData == null || dailyUnits == null) { + logger.warn("Weather request failed: invalid weather data format"); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid weather data format"); + } + + var maxTempList = (List) dailyData.get("temperature_2m_max"); + var minTempList = (List) dailyData.get("temperature_2m_min"); + + if (maxTempList == null || minTempList == null || maxTempList.isEmpty() || minTempList.isEmpty()) { + logger.warn("Weather request failed: no temperature data for date: {}", date); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No temperature data for this date"); + } + + var maxTemp = ((Number) maxTempList.getFirst()).doubleValue(); + var minTemp = ((Number) minTempList.getFirst()).doubleValue(); + var unit = (String) dailyUnits.get("temperature_2m_max"); + + String locationDisplay = cityName + (country.isEmpty() ? "" : ", " + country); + String formattedUnit = unit.replace("°", " deg "); + + logger.info("Retrieved weather for {}, {}: date: {}, min: {}{}, max: {}{}", + cityName, country, date, minTemp, formattedUnit, maxTemp, formattedUnit); + + return String.format( + "Weather for %s on %s:\nMin: %.1f%s, Max: %.1f%s", + locationDisplay, date, minTemp, formattedUnit, maxTemp, formattedUnit + ); + } +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherTools.java b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherTools.java new file mode 100644 index 00000000..0c1afdc1 --- /dev/null +++ b/samples/security/apikey/weather/src/main/java/com/example/weather/WeatherTools.java @@ -0,0 +1,52 @@ +package com.example.weather; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +/** + * This class provides tool-annotated methods for AI consumption + * while delegating actual business logic to WeatherService + */ +@Component +public class WeatherTools { + private static final Logger logger = LoggerFactory.getLogger(WeatherTools.class); + private final WeatherService weatherService; + + public WeatherTools(WeatherService weatherService) { + this.weatherService = weatherService; + } + +// @Bean +// public ToolCallbackProvider weatherToolsProvider() { +// return MethodToolCallbackProvider.builder() +// .toolObjects(this) +// .build(); +// } + + @PreAuthorize("isAuthenticated()") +// @Tool(description = """ +// Get weather forecast for a city on a specific date. +// Requires: city - Name of the city, +// date - Date in YYYY-MM-DD format. +// Returns: Weather forecast with min/max temperatures. +// Errors: BAD_REQUEST if city is missing or date format is invalid, +// NOT_FOUND if city doesn't exist or no data for date, +// SERVICE_UNAVAILABLE if weather service is down. +// """) + @McpTool(description = """ + Get weather forecast for a city on a specific date. + Requires: city - Name of the city, + date - Date in YYYY-MM-DD format. + Returns: Weather forecast with min/max temperatures. + Errors: BAD_REQUEST if city is missing or date format is invalid, + NOT_FOUND if city doesn't exist or no data for date, + SERVICE_UNAVAILABLE if weather service is down. + """) + public String getWeatherForecast(String city, String date) { + logger.info("Tool request: Getting weather for city: {} on date: {}", city, date); + return weatherService.getWeather(city, date); + } +} \ No newline at end of file diff --git a/samples/security/apikey/weather/src/main/resources/application.properties b/samples/security/apikey/weather/src/main/resources/application.properties new file mode 100644 index 00000000..f0a56030 --- /dev/null +++ b/samples/security/apikey/weather/src/main/resources/application.properties @@ -0,0 +1,19 @@ +# Server configuration +server.port=8081 + +# Application name +spring.application.name=weather-service + +# Logging configuration +logging.level.com.example.weather=INFO +logging.level.org.springframework.web.reactive.function.client=DEBUG + +# MCP Server configuration +spring.ai.mcp.server.name=weather +spring.ai.mcp.server.version=1.0.0 +spring.ai.mcp.server.protocol=stateless + +spring.ai.mcp.server.instructions="Weather: get weather forecast for a city" +logging.level.org.springframework.ai: DEBUG + +spring.security.oauth2.resourceserver.jwt.issuer-uri=${ISSUER_URI:http://localhost:9000} diff --git a/samples/security/apikey/weather/src/test/java/com/example/weather/WeatherApplicationTests.java b/samples/security/apikey/weather/src/test/java/com/example/weather/WeatherApplicationTests.java new file mode 100644 index 00000000..4dbf01a4 --- /dev/null +++ b/samples/security/apikey/weather/src/test/java/com/example/weather/WeatherApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.weather; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class WeatherApplicationTests { + + @Test + void contextLoads() { + } + +} \ No newline at end of file diff --git a/samples/security/oauth/ai-agent/pom.xml b/samples/security/oauth/ai-agent/pom.xml index c5e309b1..ed8779cf 100644 --- a/samples/security/oauth/ai-agent/pom.xml +++ b/samples/security/oauth/ai-agent/pom.xml @@ -9,10 +9,10 @@ com.example - ai-agent-secured + ai-agent-secured-oauth 0.0.1-SNAPSHOT - ai-agent - Travel and Expenses AI Agent + ai-agent-secured-oauth + Travel and Expenses AI Agent, secured with OAuth diff --git a/samples/security/oauth/weather/pom.xml b/samples/security/oauth/weather/pom.xml index 03a3e75d..00b37585 100644 --- a/samples/security/oauth/weather/pom.xml +++ b/samples/security/oauth/weather/pom.xml @@ -9,10 +9,10 @@ com.example - weather + weather-secured-oauth 0.0.1-SNAPSHOT - weather - Weather Forecast Service + weather-secured-oauth + Weather Forecast Service secured with OAuth