1
0
Fork 0
mirror of https://github.com/IRS-Public/direct-file.git synced 2025-08-17 10:10:53 +00:00

initial commit

This commit is contained in:
sps-irs 2025-05-29 13:12:11 -04:00 committed by Alexander Petros
parent 2f3ebd6693
commit e0d5c84451
3413 changed files with 794524 additions and 1 deletions

44
direct-file/state-api/.gitignore vendored Normal file
View file

@ -0,0 +1,44 @@
HELP.md
/src/main/resources/application-local.yaml
target/
!**/src/main/**/target/
!**/src/test/**/target/
!**/src/main/resources/certs/
*.jar
### 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/
### docker-related ###
/docker/db/postgres/data
### Gradle ###
.gradle/
### Spotbugs ###
src/main/resources/spotbugs/

View file

@ -0,0 +1,5 @@
changeLogFile=db/changelog.yaml
url=jdbc:postgresql://localhost:5433/stateapi
username=postgres
password=postgres
changesetAuthor=directfile

View file

@ -0,0 +1,19 @@
# 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.
wrapperVersion=3.3.2
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip

View file

@ -0,0 +1,44 @@
#syntax=docker/dockerfile:1.7-labs
# build factgraph since it will be a dependency in share-libs-builder
FROM sbtscala/scala-sbt:eclipse-temurin-alpine-21.0.2_13_1.9.9_3.3.3 AS factgraph-builder
WORKDIR /build/
COPY --from=factgraph-repo js/src/ js/src/
COPY --from=factgraph-repo jvm/src/ jvm/src/
COPY --from=factgraph-repo project/build.properties project/plugins.sbt project/
COPY --from=factgraph-repo shared/ shared/
COPY --from=factgraph-repo build.sbt .
RUN sbt compile package publishM2
# build shared dependencies
FROM eclipse-temurin:21-jdk-alpine AS shared-dependencies-builder
COPY --from=factgraph-builder /root/.m2/repository/gov/irs/factgraph/fact-graph_3/ /root/.m2/repository/gov/irs/factgraph/fact-graph_3/
ARG MAVEN_OPTS=""
WORKDIR /build/
COPY --from=config . ./config/
COPY --from=boms . ./boms/
WORKDIR /build/libs/
COPY --from=shared-libs .mvn/wrapper/maven-wrapper.properties .mvn/wrapper/
COPY --from=shared-libs mvnw ./
COPY --from=shared-libs --parents **/pom.xml ./
RUN ./mvnw dependency:resolve -P resolve
COPY --from=shared-libs starters/ ./starters/
COPY --from=shared-libs data-models/ ./data-models/
RUN ./mvnw install
# build the application
FROM shared-dependencies-builder AS state-api-builder
ARG MAVEN_OPTS=""
COPY --from=config . /config/
WORKDIR /build/
COPY mvnw pom.xml ./
COPY .mvn/wrapper/maven-wrapper.properties .mvn/wrapper/
RUN ./mvnw dependency:resolve
COPY src/ src/
RUN ./mvnw package
FROM eclipse-temurin:21-jre-alpine
COPY --from=state-api-builder /build/target/state-api-0.0.1-SNAPSHOT.jar /deployments/state-api.jar
RUN adduser --system --no-create-home jar-runner
USER jar-runner
CMD ["java", "-jar", "/deployments/state-api.jar"]

View file

@ -0,0 +1,151 @@
# Direct File State Tax API
## Quick Start
### Local Development
This project relies on locally installed Maven packages in order to build; therefore, you can run the `/scripts/build-project.sh` which will install the shared dependencies
```sh
../scripts/build-project.sh
```
### Docker
To build state-api image, run
```
docker compose build state-api
```
To quickly set up the application and run the supporting services (localstack, db), run the following command:
```bash
docker compose up state-api -d
```
The `state-api` application will be accessible at http://localhost:8081, and the PostgreSQL database will be available on port 5433 with the default username and password `postgres`.
Health check: http://localhost:8081/actuator/health
Swagger API: http://localhost:8081/swagger-ui/index.html
## Database migrations
This app uses the same Liquibase setup as the [backend](../backend) app.
See the [backend README section for database migrations for more details](../backend/README.md#database-migrations).
## Endpoints
### Save Authorization Code
Save the provided JSON data to the database.
**Endpoint:** `/state-api/authorization-code`
**Sample Data:**
Ensure value of stateCode is in state_profie table
```json
{"taxReturnUuid": "4638655a-5798-4174-a5a0-37cc3b3cd9a0", "tin": "123456789", "taxYear": 2022, "stateCode":"FS", "submissionId":"12345678901234567890"}
```
**cURL Command:**
```bash
curl -X POST -H "Content-Type: application/json" -d '{"taxReturnUuid": "4638655a-5798-4174-a5a0-37cc3b3cd9a0", "tin": "123456789", "taxYear": 2022, "stateCode":"FS", "submissionId":"12345678901234567890"}' http://localhost:8081/state-api/authorization-code -i
```
### V2: Generate New Authorization Token
Get a [JWT](https://jwt.io/) with symmetrically-encrypted metadata for future (time-limited) access to
the V2 tax return export.
**Endpoint:** `state-api/v2/authorization-token`
### Export Tax Return Data
Retrieve tax return data based on provided headers.
**Endpoint:** `/state-api/export-return`
The request includes a JWT Bearer token signed with a private key, containing the state account ID and authorization code. Export an encrypted tax return for a particular taxpayer identified by the authorization code passed in the JWT Bearer token. If the operation is successful, it will return a status of 200. In case of an error, it will also return a status of 200 along with an error code, such as,
```
E_BEARER_TOKEN_MISSING
E_AUTHORZIATION_CODE_NOT_EXIST
E_AUTHORIZATION_CODE_EXPIRED
E_AUTHORIZATION_CODE_INVALID_FORMAT
E_ACCOUNT_ID_NOT_EXIST
E_JWT_VERIFICATION_FAILED
E_CERTIFICATE_NOT_FOUND
E_CERTIFICATE_EXPIRED
E_INTERNAL_SERVER_ERROR
```
Success return:
```
{
"status": "success",
"taxReturn": "encoded-encrypted-data"
}
```
encrypted taxReturn includes return status and xml data, submissionId, directFileData, and status can be "accepted", "rejected", "pending". Sample value:
{
"status": "accepted",
"submissionId": "123456",
"xml": "return-data in xml",
"directFileData": "JSON formatted data collected by direct file"
}
Error return:
```
{
"status": "error",
"error": "E_CERTIFICATE_EXPIRED"
}
```
Testing using cURL alone may not be straightforward. We recommend using Postman for this purpose.
PostMan setting:
GET http://localhost:8081/state-api/export-return
```
Authorization: select JWT Bearer
Algorithm: RS256
Private Key: copy content from src/test/resources/certificates/fakestate.key and paste here
Payload: {"iss":"123456","sub":"cd19876a-328c-4173-b4e6-59b55f4bb99e","iat":1516239022} where "iss" for account-id, "sub" for authorization code, and "iat" is value of time.time() without quotes. Ensure value of "sub" is in authorization_code table.
Header Prefix: Bearer
```
## Cleanup
When you're finished using the application, you can tear it down with the following command:
```bash
docker compose down
```
## Clean Start up on localhost
```
1. docker compose down
2. docker container prune (select y)
3. docker compose build
4. delete docker/db/data folder (if you are not the first to run)
5. docker compose up -d
```
endpoints:
http://localhost:8081/state-api/authorization-code
http://localhost:8081/state-api/v2/authorization-token
http://localhost:8081/state-api/export-return
## Integration Tests
Make sure all dependent docker containers up running locally before doing the integration tests.
The State API app itself should not be running. The test class will spin up a properly configured instance on demand.
```
mvn test -DrunIntegrationTests -Dtest=StateApiAppTest
or
./integrationtest.sh
```

View file

@ -0,0 +1,2 @@
### docker-related ###
/db/postgres/data

View file

@ -0,0 +1 @@
CREATE DATABASE stateapi;

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
cd "$SCRIPT_DIR"
echo "$SCRIPT_DIR"
api_build_image_tag="state-api"
api_build_container_name="state-api-builder-container"
api_jar_file_name="state-api-0.0.1-SNAPSHOT.jar"
jar_output_path_component="target"
# build jar
docker buildx build \
--pull \
--build-context factgraph-repo="../fact-graph-scala" \
--build-context boms="../boms" \
--build-context config="../config" \
--build-context shared-libs="../libs" \
--build-arg MAVEN_OPTS="$MAVEN_OPTS" \
--tag "$api_build_image_tag" \
--file Dockerfile-local \
"$@" \
"$SCRIPT_DIR"
# extract jar to `./target`
mkdir -p "$SCRIPT_DIR/$jar_output_path_component"
docker container rm --force "$api_build_container_name" &>/dev/null
docker container create --name "$api_build_container_name" "$api_build_image_tag"
docker cp "$api_build_container_name":/deployments/state-api.jar "$SCRIPT_DIR/$jar_output_path_component/$api_jar_file_name"
docker container rm --force "$api_build_container_name"
printf "\njarfile: %s\n" "$SCRIPT_DIR/$jar_output_path_component/$api_jar_file_name"

View file

@ -0,0 +1 @@
./mvnw test -DrunIntegrationTests -Dtest=StateApiAppTest,StateApiAppNoOverrideTest

View file

@ -0,0 +1,3 @@
#cp -R ./app-config/* target/.
cd target
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 -jar state-api-0.0.1-SNAPSHOT.jar

259
direct-file/state-api/mvnw vendored Executable file
View file

@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -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 "$@"

149
direct-file/state-api/mvnw.cmd vendored Normal file
View file

@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
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"

View file

@ -0,0 +1,206 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>gov.irs.directfile.boot</groupId>
<artifactId>irs-spring-boot-starter-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../boms/irs-spring-boot-starter-parent</relativePath>
</parent>
<groupId>gov.irs.directfile.stateapi</groupId>
<artifactId>state-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>State API</name>
<description>state api project to download federal tax return</description>
<properties>
<!-- overrides for properties defined in irs-spring-boot-starter-parent -->
<config-folder.path>${project.basedir}/../config</config-folder.path>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- spring-boot-starter-web required for swagger-ui -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- cannot use bcpkix-jdk15on, Bouncy Castle For Java LDAP injection vulnerability -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sts</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>software.amazon.encryption.s3</groupId>
<artifactId>amazon-s3-encryption-client-java</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>r2dbc-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-annotations</artifactId>
</dependency>
<dependency>
<groupId>gov.irs.directfile</groupId>
<artifactId>data-models</artifactId>
</dependency>
<dependency>
<groupId>gov.irs.boot</groupId>
<artifactId>irs-spring-boot-starter-openfeature</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<configuration>
<propertyFile>.liquibase.properties</propertyFile>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-XX:+EnableDynamicAgentLoading ${argLine}</argLine>
<excludedEnvironmentVariables>
<!-- exclude active profiles so tests work from cli even if this var is set -->
<excludedEnvironmentVariable>SPRING_PROFILES_ACTIVE</excludedEnvironmentVariable>
</excludedEnvironmentVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<!-- Configures mvn site:run to run on port 8485 -->
<configuration>
<port>8485</port>
<tempWebappDirectory>${basedir}/target/site/tempdir</tempWebappDirectory>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<profiles>
<profile>development</profile>
<profile>debug</profile>
</profiles>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,42 @@
package gov.irs.directfile.stateapi;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.servers.Server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
import gov.irs.directfile.stateapi.configuration.CertificationOverrideProperties;
import gov.irs.directfile.stateapi.configuration.DirectFileEndpointProperties;
import gov.irs.directfile.stateapi.configuration.FeatureFlagsConfigurationProperties;
import gov.irs.directfile.stateapi.configuration.S3ConfigurationProperties;
import gov.irs.directfile.stateapi.configuration.XmlSanitizedConfigurationProperties;
@SpringBootApplication
@EnableCaching
@EnableScheduling
@EnableConfigurationProperties({
FeatureFlagsConfigurationProperties.class,
XmlSanitizedConfigurationProperties.class,
S3ConfigurationProperties.class,
DirectFileEndpointProperties.class,
CertificationOverrideProperties.class
})
@OpenAPIDefinition(
info = @Info(title = "State Tax API", description = "The State Tax API", version = "1.0.1"),
servers = {
@Server(url = "https://df.services.irs.gov", description = "Prod Server"),
@Server(url = "https://df.alt.services.irs.gov", description = "ATS Server"),
@Server(url = "http://locahost:8081/state-api", description = "Local Development")
},
security = {@SecurityRequirement(name = "PreauthenticatedHeader")})
public class StateApiApp {
public static void main(String[] args) {
SpringApplication.run(StateApiApp.class, args);
}
}

View file

@ -0,0 +1,17 @@
package gov.irs.directfile.stateapi.audit;
public enum AuditLogElement {
cyberOnly,
detail,
eventErrorMessage,
eventId,
eventStatus,
eventType,
stateId,
taxReturnId,
responseStatusCode,
system,
taxPeriod,
timestamp,
userType;
}

View file

@ -0,0 +1,68 @@
package gov.irs.directfile.stateapi.audit;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.slf4j.spi.LoggingEventBuilder;
import org.springframework.stereotype.Service;
import gov.irs.directfile.stateapi.events.Event;
import gov.irs.directfile.stateapi.events.EventStatus;
@Service
@Slf4j
public class AuditService {
public void logEvent(Event event) {
if (event != null) {
Map<String, String> contextMapCopy = MDC.getCopyOfContextMap();
LoggingEventBuilder loggingEventBuilder =
(event.getEventStatus() == EventStatus.FAILURE) ? log.atError() : log.atInfo();
try {
if (event.getEventId() != null) {
MDC.put(
AuditLogElement.eventId.toString(),
event.getEventId().toString());
}
if (event.getEventStatus() != null) {
MDC.put(
AuditLogElement.eventStatus.toString(),
event.getEventStatus().toString());
}
if (event.getResponseStatusCode() != null) {
MDC.put(AuditLogElement.responseStatusCode.toString(), event.getResponseStatusCode());
}
if (event.getEventErrorMessage() != null) {
MDC.put(AuditLogElement.eventErrorMessage.toString(), event.getEventErrorMessage());
}
if (event.getTaxPeriod() != null) {
MDC.put(AuditLogElement.taxPeriod.toString(), event.getTaxPeriod());
}
if (event.getStateId() != null) {
MDC.put(AuditLogElement.stateId.toString(), event.getStateId());
}
if (event.getUserType() != null) {
MDC.put(AuditLogElement.userType.toString(), event.getUserType());
}
if (event.getTaxReturnId() != null) {
MDC.put(AuditLogElement.taxReturnId.toString(), event.getTaxReturnId());
}
if (event.getDetail() != null) {
loggingEventBuilder = loggingEventBuilder.addKeyValue(
AuditLogElement.detail.toString(), event.getDetail().getDetailMap());
}
MDC.put(AuditLogElement.eventType.toString(), "STATE_API");
MDC.put(AuditLogElement.cyberOnly.toString(), Boolean.TRUE.toString());
loggingEventBuilder.log();
} finally {
// reset the MDC context map
MDC.setContextMap(contextMapCopy);
}
}
}
}

View file

@ -0,0 +1,45 @@
package gov.irs.directfile.stateapi.audit;
import ch.qos.logback.classic.pattern.ThrowableProxyConverter;
import ch.qos.logback.classic.spi.IThrowableProxy;
import ch.qos.logback.classic.spi.ThrowableProxyUtil;
import ch.qos.logback.core.CoreConstants;
/*
* https://logback.qos.ch/apidocs/src-html/ch/qos/logback/classic/pattern/RootCauseFirstThrowableProxyConverter.html
*
* This is a slightly modified version of the linked class
* that omits the exception message from the stack trace.
*/
@SuppressWarnings("PMD.AvoidReassigningParameters")
public class NoMessageStackTraceConverter extends ThrowableProxyConverter {
@Override
protected String throwableProxyToString(IThrowableProxy tp) {
StringBuilder buf = new StringBuilder(BUILDER_CAPACITY);
recursiveAppendRootCauseFirst(buf, null, ThrowableProxyUtil.REGULAR_EXCEPTION_INDENT, tp);
return buf.toString();
}
protected void recursiveAppendRootCauseFirst(StringBuilder sb, String prefix, int indent, IThrowableProxy tp) {
if (tp.getCause() != null) {
recursiveAppendRootCauseFirst(sb, prefix, indent, tp.getCause());
prefix = null;
}
ThrowableProxyUtil.indent(sb, indent - 1);
if (prefix != null) {
sb.append(prefix);
}
sb.append(tp.getClassName());
sb.append(CoreConstants.LINE_SEPARATOR);
subjoinSTEPArray(sb, indent, tp);
IThrowableProxy[] suppressed = tp.getSuppressed();
if (suppressed != null) {
for (IThrowableProxy current : suppressed) {
recursiveAppendRootCauseFirst(
sb, CoreConstants.SUPPRESSED, indent + ThrowableProxyUtil.SUPPRESSED_EXCEPTION_INDENT, current);
}
}
}
}

View file

@ -0,0 +1,6 @@
package gov.irs.directfile.stateapi.authorization;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record AuthorizationTokenClaims(String taxReturnUuid, String submissionId, String stateCode, int taxYear) {}

View file

@ -0,0 +1,77 @@
package gov.irs.directfile.stateapi.authorization;
import java.time.Instant;
import java.util.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jwt.JWTClaimsSet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import gov.irs.directfile.models.encryption.DataEncryptDecrypt;
@Slf4j
@Service
public class AuthorizationTokenService {
private final DataEncryptDecrypt dataEncryptDecrypt;
public static final String EXPORT_CLAIM_KEY = "tax-return-export-metadata";
private final ObjectMapper mapper = new ObjectMapper();
private final int authorizationCodeExpiresInterval;
private final String signingKey;
public AuthorizationTokenService(
DataEncryptDecrypt dataEncryptDecrypt,
@Value("${authorization-token.signing-key}") String signingKey,
@Value("${authorization-code.expires-interval-seconds: 600}") int authorizationCodeExpiresInterval) {
this.dataEncryptDecrypt = dataEncryptDecrypt;
this.signingKey = signingKey;
this.authorizationCodeExpiresInterval = authorizationCodeExpiresInterval;
}
/**
* Creates a JWT (json web token) containing tax return export metadata which is first signed and then encrypted.
*/
public Mono<String> generateAndEncrypt(AuthorizationTokenClaims claims) {
return Mono.fromCallable(() -> {
JWSSigner signer = new MACSigner(signingKey);
Instant issuedAt = Instant.now();
JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
.claim(EXPORT_CLAIM_KEY, claims)
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(issuedAt.plusSeconds(authorizationCodeExpiresInterval)))
.build();
// create and sign a JWS (json web signature) containing the
// claims as the payload
JWSObject jwsObject = new JWSObject(
new JWSHeader.Builder(JWSAlgorithm.HS256).build(), new Payload(jwtClaimsSet.toJSONObject()));
jwsObject.sign(signer);
// encrypt the token with kms encryption sdk
return encryptToken(signedJWSToBytes(jwsObject.serialize()));
});
}
private byte[] signedJWSToBytes(String serializedJws) throws JsonProcessingException {
// break the serialized JWSObject into its period-separated parts
// before converting to byte array. We must do this prior to encryption to preserve
// the separate parts of the token (header, signature, and payload)
String[] serializedJWSParts = serializedJws.split("\\.");
SignedJWSParts jwsParts =
new SignedJWSParts(serializedJWSParts[0], serializedJWSParts[1], serializedJWSParts[2]);
return mapper.writeValueAsBytes(jwsParts);
}
private String encryptToken(byte[] claims) {
Map<String, String> encryptionContext = new HashMap<>();
encryptionContext.put("system", "DIRECT-FILE");
encryptionContext.put("type", "STATE-API");
byte[] ciphertext = dataEncryptDecrypt.encrypt(claims, encryptionContext);
return Base64.getUrlEncoder().encodeToString(ciphertext);
}
}

View file

@ -0,0 +1,3 @@
package gov.irs.directfile.stateapi.authorization;
public record SignedJWSParts(String s1, String s2, String s3) {}

View file

@ -0,0 +1,31 @@
package gov.irs.directfile.stateapi.configuration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@Configuration
@EnableConfigurationProperties(S3ConfigurationProperties.class)
public class AwsCredentialsConfiguration {
@Bean
@ConditionalOnProperty(
name = "aws.s3.default-credentials-provider-chain-enabled",
havingValue = "false",
matchIfMissing = true)
public AwsCredentialsProvider staticCredentialsProvider(S3ConfigurationProperties s3ConfigurationProperties) {
return StaticCredentialsProvider.create(AwsBasicCredentials.create(
s3ConfigurationProperties.getAccessKey(), s3ConfigurationProperties.getSecretKey()));
}
@Bean
@ConditionalOnProperty(name = "aws.s3.default-credentials-provider-chain-enabled", havingValue = "true")
public AwsCredentialsProvider defaultAWSCredentialsProvider() {
return DefaultCredentialsProvider.create();
}
}

View file

@ -0,0 +1,37 @@
package gov.irs.directfile.stateapi.configuration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/***
* Configures the cache used by Spring @Cacheable methods, such as those found in the CachedDataService
*/
@Configuration
public class CacheConfiguration {
@Value("${spring.cache.TTL-minutes: 120}")
private long cacheTTL;
@Bean
public Caffeine<Object, Object> caffeineConfig() {
return Caffeine.newBuilder()
.expireAfterWrite(cacheTTL, TimeUnit.MINUTES)
.maximumSize(100);
}
@Bean
public CacheManager cacheManager(Caffeine<Object, Object> caffeine) {
var caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(caffeine);
caffeineCacheManager.setCacheNames(List.of("publicKeyCache", "stateProfileCache"));
caffeineCacheManager.setAsyncCacheMode(true);
return caffeineCacheManager;
}
}

View file

@ -0,0 +1,14 @@
package gov.irs.directfile.stateapi.configuration;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@RequiredArgsConstructor
@Getter
@Validated
@ConfigurationProperties(prefix = "direct-file")
public class CertificationOverrideProperties {
private final String certLocationOverride;
}

View file

@ -0,0 +1,21 @@
package gov.irs.directfile.stateapi.configuration;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@RequiredArgsConstructor
@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = "direct-file")
public class DirectFileEndpointProperties {
@NonNull private String backendUrl;
@NonNull private String backendContextPath;
@NonNull private String backendApiVersion;
}

View file

@ -0,0 +1,51 @@
package gov.irs.directfile.stateapi.configuration;
import java.net.URI;
import java.util.Base64;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import lombok.AllArgsConstructor;
import lombok.NonNull;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.kms.KmsClient;
import software.amazon.encryption.s3.materials.CryptographicMaterialsManager;
import software.amazon.encryption.s3.materials.DefaultCryptoMaterialsManager;
import software.amazon.encryption.s3.materials.KmsKeyring;
@Configuration
@AllArgsConstructor
@EnableConfigurationProperties({S3ConfigurationProperties.class, EncryptionConfigurationProperties.class})
public class EncryptionClientConfiguration {
private final EncryptionConfigurationProperties encryptionConfig;
private final S3ConfigurationProperties s3ConfigurationProperties;
private final AwsCredentialsProvider awsCredentialsProvider;
public CryptographicMaterialsManager kmsCrypto(@NonNull String kmsWrappingKeyArn) {
return DefaultCryptoMaterialsManager.builder()
.keyring(KmsKeyring.builder()
.kmsClient(regionalKmsClient())
.wrappingKeyId(kmsWrappingKeyArn)
.build())
.build();
}
public SecretKey getLocalAesWrappingKey() {
return new SecretKeySpec(Base64.getDecoder().decode(encryptionConfig.getLocalWrappingKey()), "AES");
}
public String getS3KmsWrappingKeyArn() {
return encryptionConfig.getS3KmsWrappingKeyArn();
}
protected KmsClient regionalKmsClient() {
return KmsClient.builder()
.region(Region.of(encryptionConfig.getRegion()))
.credentialsProvider(awsCredentialsProvider)
.endpointOverride(URI.create(encryptionConfig.getKmsEndpoint()))
.build();
}
}

View file

@ -0,0 +1,23 @@
package gov.irs.directfile.stateapi.configuration;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
@AllArgsConstructor
@Getter
@ConfigurationProperties
public class EncryptionConfigurationProperties {
@Value("${aws.kmsEndpoint:#{null}}")
private String kmsEndpoint;
@Value("${aws.region:#{null}}")
private String region;
@Value("${direct-file.local-encryption.local-wrapping-key:#{null}}")
private String localWrappingKey;
@Value("${aws.s3.s3-kms-wrapping-key-arn:#{null}}")
private String s3KmsWrappingKeyArn;
}

View file

@ -0,0 +1,22 @@
package gov.irs.directfile.stateapi.configuration;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter
@AllArgsConstructor
@ConfigurationProperties(prefix = "feature-flags")
public class FeatureFlagsConfigurationProperties {
private final ExportReturnFlags exportReturn;
public interface Toggleable {
boolean isEnabled();
}
@Getter
@AllArgsConstructor
public static class ExportReturnFlags implements Toggleable {
private boolean enabled;
}
}

View file

@ -0,0 +1,83 @@
package gov.irs.directfile.stateapi.configuration;
import java.net.URI;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3AsyncClientBuilder;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
import software.amazon.encryption.s3.S3AsyncEncryptionClient;
@Configuration
@AllArgsConstructor
public class S3ClientConfiguration {
private final EncryptionClientConfiguration encryptionClientConfiguration;
private final S3ConfigurationProperties s3ConfigurationProperties;
private final AwsCredentialsProvider awsCredentialsProvider;
@Bean
@Primary
S3AsyncClient getS3AsyncClient() {
return baseS3Client(s3ConfigurationProperties);
}
@Bean
@Profile("aws")
public S3AsyncEncryptionClient s3AsyncEncryptionClient() {
return S3AsyncEncryptionClient.builder()
.wrappedClient(baseS3Client(s3ConfigurationProperties))
.cryptoMaterialsManager(
encryptionClientConfiguration.kmsCrypto(encryptionClientConfiguration.getS3KmsWrappingKeyArn()))
.build();
}
@Bean
@Profile("!aws")
public S3AsyncEncryptionClient localS3AsyncEncryptionClient() {
return S3AsyncEncryptionClient.builder()
.wrappedClient(baseS3Client(s3ConfigurationProperties))
.aesKey(encryptionClientConfiguration.getLocalAesWrappingKey())
.build();
}
private S3AsyncClient baseS3Client(S3ConfigurationProperties s3ConfigurationProperties) {
S3AsyncClientBuilder builder = S3AsyncClient.builder().region(Region.of(s3ConfigurationProperties.getRegion()));
if (s3ConfigurationProperties.getAssumeRoleArn() != null
&& StringUtils.isNotBlank(s3ConfigurationProperties.getAssumeRoleArn())) {
builder.credentialsProvider(StsAssumeRoleCredentialsProvider.builder()
.stsClient(StsClient.builder()
.region(Region.of(s3ConfigurationProperties.getRegion()))
.credentialsProvider(awsCredentialsProvider)
.build())
.refreshRequest(AssumeRoleRequest.builder()
.roleArn(s3ConfigurationProperties.getAssumeRoleArn())
.roleSessionName(s3ConfigurationProperties.getAssumeRoleSessionName())
.durationSeconds(Integer.parseInt(s3ConfigurationProperties.getAssumeRoleDurationSeconds()))
.build())
.build());
} else {
builder.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
s3ConfigurationProperties.getAccessKey(), s3ConfigurationProperties.getSecretKey())));
}
if (s3ConfigurationProperties.getEndPoint() != null
&& StringUtils.isNotBlank(s3ConfigurationProperties.getEndPoint())) {
builder.endpointOverride(URI.create(s3ConfigurationProperties.getEndPoint()))
.forcePathStyle(true); // <-- this fixes the UnknonwnHost issue with localstack
}
return builder.build();
}
}

View file

@ -0,0 +1,25 @@
package gov.irs.directfile.stateapi.configuration;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Getter
@Setter
@NoArgsConstructor
@Validated
@ConfigurationProperties(prefix = "aws.s3")
public class S3ConfigurationProperties {
private String accessKey;
private String secretKey;
private String region;
private String assumeRoleArn;
private String assumeRoleDurationSeconds;
private String assumeRoleSessionName;
private String endPoint;
private String certBucketName;
private String taxReturnXmlBucketName;
private String prefix;
}

View file

@ -0,0 +1,15 @@
package gov.irs.directfile.stateapi.configuration;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "xml-sanitized")
public class XmlSanitizedConfigurationProperties {
private final List<String> allowedHeaders;
private final List<String> excludedTags;
}

View file

@ -0,0 +1,90 @@
package gov.irs.directfile.stateapi.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import reactor.core.publisher.Mono;
import gov.irs.directfile.dto.AuthCodeResponse;
import gov.irs.directfile.stateapi.model.AuthCodeRequest;
import gov.irs.directfile.stateapi.model.ExportResponse;
@Tag(name = "State-Tax-API", description = "Direct File State Tax APIs")
@SuppressWarnings("PMD.UnnecessaryModifier")
public interface StateApi {
@Operation(
summary = "Create authorization code",
description =
"Create an authorization code for the given tax return UUID. This API will be called from Direct File service.")
@ApiResponses({
@ApiResponse(
responseCode = "201",
description = "Authorization code created",
content =
@Content(
mediaType = "text/plain",
schema = @Schema(implementation = String.class),
examples =
@ExampleObject(
name = "authorization code example",
value = "cd19876a-328c-4173-b4e6-59b55f4bb99e"))),
@ApiResponse(
responseCode = "500",
description =
"Error occurred to create authorization code. For tax return not accepted, error message is E_TAX_RETURN_NOT_ACCEPTED; for tax return not found, error message is E_TAX_RETURN_NOT_FOUND",
content = {
@Content(
mediaType = "text/plain",
examples = {
@ExampleObject(
name = "Internal server error",
value = "An internal server error occurred.")
})
})
})
Mono<ResponseEntity<AuthCodeResponse>> createAuthorizationCode(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Request payload for creating the authorization code",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = AuthCodeRequest.class),
examples = @ExampleObject(value = AuthCodeRequest.docsExampleObject)))
@Valid
@RequestBody
AuthCodeRequest ac,
HttpServletRequest request);
@Operation(
summary = "Export tax return",
description =
"Export an encrypted tax return for a particular taxpayer identified by the authorization code passed in the JWT Bearer token. If the operation is successful, it will return a status of 200. In case of an error, it will also return a status of 200 along with an error code,"
+ "e.g. {\"status\": \"error\", \"error\": \"E_CERTIFICATE_EXPIRED\"}. This API will be called from state-app.")
@ApiResponse(
responseCode = "200",
description = "export taxpayer's federal tax return",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = ExportResponse.class),
examples = @ExampleObject(value = ExportResponse.docsExampleObjectSuccess))
})
public Mono<ResponseEntity<ExportResponse>> exportReturn(
@Parameter(
description = "Authorization header",
required = true,
examples = {@ExampleObject(name = "JWT Bearer token", value = "Bearer token")})
@RequestHeader("Authorization")
String authorizationHeader,
HttpServletRequest request);
}

View file

@ -0,0 +1,293 @@
package gov.irs.directfile.stateapi.controller;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import dev.openfeature.sdk.Client;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import gov.irs.directfile.dto.AuthCodeResponse;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.dto.StateProfileDTO;
import gov.irs.directfile.stateapi.encryption.JwtVerifier;
import gov.irs.directfile.stateapi.exception.StateApiException;
import gov.irs.directfile.stateapi.exception.StateNotExistException;
import gov.irs.directfile.stateapi.model.*;
import gov.irs.directfile.stateapi.service.StateApiService;
@RestController
@RequestMapping("/state-api")
@Validated
@Slf4j
@SuppressWarnings({"PMD.MissingOverride", "PMD.ExcessiveParameterList"})
@RequiredArgsConstructor
public class StateApiController implements StateApi {
public static final String SESSION_KEY = "SESSION-KEY";
public static final String INITIALIZATION_VECTOR = "INITIALIZATION-VECTOR";
public static final String AUTHENTICATION_TAG = "AUTHENTICATION-TAG";
private final StateApiService svc;
private final Client featureFlagClient;
private static final String X_HEADER = "x-header";
@PostMapping("/authorization-code")
@ResponseStatus(HttpStatus.CREATED)
public Mono<ResponseEntity<AuthCodeResponse>> createAuthorizationCode(
@Valid @RequestBody AuthCodeRequest ac, HttpServletRequest request) {
log.info("enter createAuthorizationCode()...");
String taxReturnId = ac.getTaxReturnUuid().toString();
String submissionId = ac.getSubmissionId();
return svc.createAuthorizationCode(ac)
.map(uuid -> ResponseEntity.ok(
AuthCodeResponse.builder().authCode(uuid).build()))
.doOnSuccess(response -> {
log.info(
"createAuthorizationCode() succeeded for taxYear: {}, stateCode: {}, taxReturnId: {}",
ac.getTaxYear(),
ac.getStateCode(),
taxReturnId);
})
.onErrorResume(e -> {
log.error(
"createAuthorizationCode() failed for taxYear: {}, stateCode: {}, taxReturnId: {}, submissionId: {}, {}, error: {}",
ac.getTaxYear(),
ac.getStateCode(),
taxReturnId,
submissionId,
e.getClass().getName(),
e.getMessage());
if (e instanceof StateApiException stateApiException) {
// TODO: Http Status should be dependent on the error code. For now, since
// StateApiExceptionHandler previously returned 500 for any unhandled exception, this keeps the
// status the same
return Mono.just(ResponseEntity.internalServerError()
.body(AuthCodeResponse.builder()
.errorCode(stateApiException.getErrorCode())
.build()));
} else {
return Mono.error(e);
}
});
}
/**
**
* Prerequisite:**
* The state will generate a key pair and send the public key (e.g., AZ.cer,
* NY.cer) to the IRS.
*
* DF State-Tax-Api (Server):**
* 1. Generate a secret (32 bytes).
* 2. Generate an initialization vector (IV) value (12 bytes).
* 3. Encrypt the tax return XML with the secret from step 1 and the IV from
* step 2 using the AES-256 GCM algorithm (plus Authentication Tag).
* 4. Base64 encode the encrypted tax return XML from step 3.
* 5. Encrypt the 32-byte secret using the public key provided by the state
* (RSA).
* 6. Base64 encode the encrypted secret from step 5.
* 7. Base64 encode the IV from step 2.
* 8. Base64 encode the Authentication Tag from step 3.
* 9. Send HTTP response to the State-App with:
* a) Header: SESSION-KEY=value from step 6
* b) Header: INITIALIZATION_VECTOR=value from step 7
* c) Header: AUTHENTICATION-TAG=value from step 8
* d) Body: value from step 4.
*
* State-App Side (Client):**
* 1. Extract SESSION-KEY from the HTTP response header.
* 2. Base64 decode the SESSION-KEY.
* 3. Decrypt the decoded SESSION-KEY with its private key (RSA) (in byte[]).
* 4. Extract INITIALIZATION_VECTOR (IV) from the HTTP response header.
* 5. Base64 decode the IV.
* 6. Extract AUTHENTICATION_TAG from the HTTP response header.
* 7. Base64 decode AUTHENTICATION_TAG.
* 6. Decrypt the XML data from the response body using the secret from step 3,
* the IV from step 5 using the AES-256 GCM algorithm, verify with
* Authentication Tag from step 7.
*
* @param authorizationHeader
* the JWT Bearer token
* @return the encrypted and BASE 64 encoded tax return xml along with two
* headers in HTTP Response: SESSION-KEY, INITIALIZATION-VECTOR and
* AUTHENTICATION-TAG.
* http status code: 200 for all responses.
*
* if success, response as:
* {"status": "success", "taxReturn": "encoded-encrypted-data"}
* (taxReturn includes return status and xml data, status can be
* "accepted", "rejected", "pending". Sample value:
* {"status":"accepted", "xml":"return-data"})
* if failed, response as:
* {"status": "error", "error": "E_AUTHORIZATION_CODE_EXPIRED"}
*/
@GetMapping("/export-return")
public Mono<ResponseEntity<ExportResponse>> exportReturn(
@RequestHeader("Authorization") String authorizationHeader, HttpServletRequest request) {
log.info("Enter exportReturn()...");
AtomicInteger taxYear = new AtomicInteger();
AtomicReference<String> stateCode = new AtomicReference<>();
stateCode.set(getStateCode(request));
AtomicReference<String> taxReturnId = new AtomicReference<>();
AtomicReference<String> submissionId = new AtomicReference<>();
taxReturnId.set("");
submissionId.set("");
if (Boolean.FALSE.equals(this.featureFlagClient.getBooleanValue("export-return", Boolean.TRUE))) {
log.info("Export return is disabled via configuration property. No action taken");
return Mono.just(ResponseEntity.ok(handleErrors(
null,
taxYear.get(),
stateCode.get(),
taxReturnId.get(),
submissionId.get(),
new StateApiException(StateApiErrorCode.E_STATE_API_DISABLED))));
}
String prefix = "Bearer ";
if (!authorizationHeader.startsWith(prefix)) {
return Mono.just(ResponseEntity.ok(handleErrors(
null,
taxYear.get(),
stateCode.get(),
taxReturnId.get(),
submissionId.get(),
new StateApiException(StateApiErrorCode.E_BEARER_TOKEN_MISSING))));
}
String jwtToken = authorizationHeader.substring(prefix.length());
final String accountId;
try {
accountId = JwtVerifier.getAccountId(jwtToken);
} catch (StateApiException e) {
return Mono.just(ResponseEntity.ok(
handleErrors(null, taxYear.get(), stateCode.get(), taxReturnId.get(), submissionId.get(), e)));
}
return svc.verifyJwtSignature(jwtToken, accountId)
.flatMap(saCode -> {
stateCode.set(saCode.getStateCode());
return svc.authorize(saCode).flatMap(entity -> {
taxReturnId.set(entity.getTaxReturnUuid().toString());
taxYear.set(entity.getTaxYear());
return svc.retrieveTaxReturnXml(
taxYear.get(), entity.getTaxReturnUuid(), entity.getSubmissionId())
.flatMap(trXml -> {
submissionId.set(trXml.submissionId());
return svc.retrieveExportedFacts(
entity.getSubmissionId(), stateCode.get(), accountId)
.flatMap(exportedFacts -> {
TaxReturnToExport trExpt = new TaxReturnToExport(
trXml.status(),
trXml.submissionId(),
trXml.xml(),
exportedFacts);
return svc.encryptTaxReturn(trExpt, accountId)
.map(ed -> ResponseEntity.ok()
.header(SESSION_KEY, ed.encodedSecret())
.header(INITIALIZATION_VECTOR, ed.encodedIV())
.header(
AUTHENTICATION_TAG,
ed.encodedAuthenticationTag())
.body(ExportResponse.builder()
.status("success")
.taxReturn(ed.encodedAndEncryptedData())
.build()));
});
});
});
})
.doOnSuccess(export -> {
log.info(
"exportReturn succeeded for taxYear: {}, accountId: {}, stateCode: {}, taxReturnId: {}, submissionId: {}",
taxYear.get(),
accountId,
stateCode.get(),
taxReturnId.get(),
submissionId.get());
})
.onErrorResume(e -> Mono.just(ResponseEntity.ok(handleErrors(
accountId, taxYear.get(), stateCode.get(), taxReturnId.get(), submissionId.get(), e))));
}
@GetMapping("/state-profile")
public Mono<ResponseEntity<StateProfileDTO>> getStateProfile(
@RequestParam String stateCode, HttpServletRequest request) {
log.info("enter getStateProfile()...");
return svc.lookupStateProfile(stateCode)
.map(ResponseEntity::ok)
.doOnSuccess(export -> {
log.info("getStateProfile completes successfully for stateCode {}", stateCode);
})
.onErrorResume(StateNotExistException.class, e -> {
log.info("No state profile found for {}, {}", stateCode, e.getMessage());
return Mono.just(ResponseEntity.noContent().build());
})
.onErrorResume(Mono::error);
}
private ExportResponse handleErrors(
String accountId, int taxYear, String stateCode, String taxReturnId, String submissionId, Throwable t) {
StateApiErrorCode errorCode;
log.error(
"exportReturn failed for taxYear: {}, accountId: {}, stateCode: {}, taxReturnId: {}, submissionId: {}, {}, error: {}",
taxYear,
accountId,
stateCode,
taxReturnId,
submissionId,
t.getClass().getName(),
t.getMessage());
if (t instanceof StateApiException e) {
// Hide specific error codes from the client, return internal server error
// instead
if (e.getErrorCode() == StateApiErrorCode.E_CERTIFICATE_NOT_FOUND
|| e.getErrorCode() == StateApiErrorCode.E_TAX_RETURN_NOT_FOUND) {
errorCode = StateApiErrorCode.E_INTERNAL_SERVER_ERROR;
} else if (e.getErrorCode() == StateApiErrorCode.E_EXPORTED_FACTS_DISABLED) {
errorCode = StateApiErrorCode.E_INTERNAL_SERVER_ERROR;
log.info("{}: {}", e.getErrorCode(), e.getMessage());
} else {
errorCode = e.getErrorCode();
}
} else {
errorCode = StateApiErrorCode.E_INTERNAL_SERVER_ERROR;
}
return ExportResponse.builder().status("error").error(errorCode.name()).build();
}
private String getStateCode(HttpServletRequest request) {
String stateInfo = request.getHeader(X_HEADER);
String stateCd = "";
// if (stateInfo != null && !stateInfo.isEmpty()) {
if (StringUtils.isNotBlank(stateInfo)) {
stateCd = stateInfo.substring(0, 2);
} else {
log.info(X_HEADER + " does not exist or is empty");
}
return stateCd;
}
}

View file

@ -0,0 +1,50 @@
package gov.irs.directfile.stateapi.dto;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import gov.irs.directfile.stateapi.model.StateLanguage;
import gov.irs.directfile.stateapi.model.StateProfile;
import gov.irs.directfile.stateapi.model.StateRedirect;
public record StateProfileDTO(
String stateCode,
String taxSystemName,
String landingUrl,
String defaultRedirectUrl,
String departmentOfRevenueUrl,
String filingRequirementsUrl,
String transferCancelUrl,
String waitingForAcceptanceCancelUrl,
List<String> redirectUrls,
Map<String, String> languages,
Boolean acceptedOnly,
String customFilingDeadline,
Boolean archived) {
public StateProfileDTO(StateProfile stateProfile) {
this(stateProfile, new ArrayList<>(), new ArrayList<>());
}
public StateProfileDTO(
StateProfile stateProfile, List<StateRedirect> stateRedirects, List<StateLanguage> stateLanguages) {
this(
stateProfile.getStateCode(),
stateProfile.getTaxSystemName(),
stateProfile.getLandingUrl(),
stateProfile.getDefaultRedirectUrl(),
stateProfile.getDepartmentOfRevenueUrl(),
stateProfile.getFilingRequirementsUrl(),
stateProfile.getTransferCancelUrl(),
stateProfile.getWaitingForAcceptanceCancelUrl(),
stateRedirects.stream().map(StateRedirect::getRedirectUrl).toList(),
stateLanguages.stream()
.collect(Collectors.toMap(
StateLanguage::getDfLanguageCode, StateLanguage::getStateLanguageCode)),
stateProfile.getAcceptedOnly(),
stateProfile.getCustomFilingDeadline(),
stateProfile.getArchived());
}
}

View file

@ -0,0 +1,54 @@
package gov.irs.directfile.stateapi.encryption;
import java.util.Arrays;
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.engines.AESEngine;
import org.bouncycastle.crypto.modes.GCMBlockCipher;
import org.bouncycastle.crypto.modes.GCMModeCipher;
import org.bouncycastle.crypto.params.AEADParameters;
import org.bouncycastle.crypto.params.KeyParameter;
/**
* This class contains sample code for decrypting the tax return, which will be shared with the client to assist in the decryption process.
*/
@SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull")
public class DecryptionDemoCode {
static int AES256_GCM_SECRET_LENGTH = 32;
static int AES256_GCM_IV_LENGTH = 12;
/**
* Decrypt data using AES 256 GCM.
* @param ciphertext the data encrypted
* @param encryptionKey the encryption secret
* @param iv the initialization vector
* @param authenticationTag the authentication tag
* @return encrypted data in byte array or null if authentication tag is invalid
* @throws InvalidCipherTextException
*/
public static byte[] aesGcmDecrypt(byte[] ciphertext, byte[] encryptionKey, byte[] iv, byte[] authenticationTag)
throws InvalidCipherTextException {
assert encryptionKey.length == AES256_GCM_SECRET_LENGTH
: "AES 256 secret is not " + AES256_GCM_SECRET_LENGTH + " bytes";
assert iv.length == AES256_GCM_IV_LENGTH : "AES 256 IV is not " + AES256_GCM_IV_LENGTH + " bytes";
// Create AES-GCM block cipher
GCMModeCipher gcmCipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
// Initialize the cipher for decryption
gcmCipher.init(false, new AEADParameters(new KeyParameter(encryptionKey), 128, iv));
byte[] decrypted = new byte[gcmCipher.getOutputSize(ciphertext.length)];
int len = gcmCipher.processBytes(ciphertext, 0, ciphertext.length, decrypted, 0);
gcmCipher.doFinal(decrypted, len);
// To verify the authentication tag, compare it to the original authentication tag
if (Arrays.equals(authenticationTag, gcmCipher.getMac())) {
return decrypted;
} else {
return null; // Authentication tag verification failed
}
}
}

View file

@ -0,0 +1,82 @@
package gov.irs.directfile.stateapi.encryption;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.engines.AESEngine;
import org.bouncycastle.crypto.modes.GCMBlockCipher;
import org.bouncycastle.crypto.modes.GCMModeCipher;
import org.bouncycastle.crypto.params.AEADParameters;
import org.bouncycastle.crypto.params.KeyParameter;
import gov.irs.directfile.stateapi.model.AesGcmEncryptionResult;
@SuppressWarnings("PMD.SignatureDeclareThrowsException")
public class Encryptor {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
static int AES256_GCM_SECRET_LENGTH = 32;
static int AES256_GCM_IV_LENGTH = 12;
public static byte[] generatePassword() throws NoSuchAlgorithmException {
// Create a KeyGenerator for AES (Advanced Encryption Standard) with a 256-bit
// key size
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(AES256_GCM_SECRET_LENGTH * 8); // 256 bits = 32 bytes
// Generate the secret key
SecretKey secretKey = keyGenerator.generateKey();
// Get the encoded bytes of the secret key
return secretKey.getEncoded();
}
public static byte[] generateIV() {
// AES-256 GCM requires 12 bytes initialization vector
byte[] iv = new byte[AES256_GCM_IV_LENGTH];
// Generate random bytes for the IV
SECURE_RANDOM.nextBytes(iv);
return iv;
}
public static byte[] rsaEncryptWithPublicKey(byte[] secret, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWITHSHA-256ANDMGF1PADDING");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return cipher.doFinal(secret);
}
public static AesGcmEncryptionResult aesGcmEncrypt(String plainText, byte[] aesKey, byte[] aesIV)
throws InvalidCipherTextException {
assert aesKey.length == AES256_GCM_SECRET_LENGTH
: "AES 256 secret is not " + AES256_GCM_SECRET_LENGTH + " bytes";
assert aesIV.length == AES256_GCM_IV_LENGTH : "AES 256 IV is not " + AES256_GCM_IV_LENGTH + " bytes";
// Plain text to be encrypted
byte[] plaintextBytes = plainText.getBytes(StandardCharsets.UTF_8);
// Create AES-GCM block cipher
GCMModeCipher gcmCipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
// Initialize with encryption mode, AES-256 key, and IV
gcmCipher.init(true, new AEADParameters(new KeyParameter(aesKey), 128, aesIV));
// Create a byte array for the ciphertext and associated data
byte[] ciphertext = new byte[gcmCipher.getOutputSize(plaintextBytes.length)];
// Encrypt the plaintext
int len = gcmCipher.processBytes(plaintextBytes, 0, plaintextBytes.length, ciphertext, 0);
gcmCipher.doFinal(ciphertext, len);
// Retrieve the authentication tag
byte[] authenticationTag = gcmCipher.getMac();
return new AesGcmEncryptionResult(ciphertext, authenticationTag);
}
}

View file

@ -0,0 +1,83 @@
package gov.irs.directfile.stateapi.encryption;
import java.io.FileNotFoundException;
import java.security.PublicKey;
import java.security.cert.CertificateException;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.util.UUID;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.micrometer.common.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.exception.StateApiException;
import gov.irs.directfile.stateapi.model.ClientJwtClaim;
@Slf4j
@SuppressWarnings({"PMD.AvoidUncheckedExceptionsInSignatures", "PMD.PreserveStackTrace"})
public class JwtVerifier {
static ObjectMapper objectMapper = new ObjectMapper();
static {
// Jwt treats 'iat' as Instant type, cannot convert claim map to Java Bean via convertValue without setting
// JavaTimeModule
JavaTimeModule module = new JavaTimeModule();
objectMapper.registerModule(module);
}
public static ClientJwtClaim verifyJwt(String jwtString, PublicKey publicKey)
throws CertificateException, FileNotFoundException, JwtException, JsonMappingException,
JsonProcessingException {
// assume publicKey is RSAPublicKey, if not, that's okay, let it throw error
JwtDecoder jwtDecoder =
NimbusJwtDecoder.withPublicKey((RSAPublicKey) publicKey).build();
Jwt jwt = jwtDecoder.decode(jwtString);
return objectMapper.convertValue(jwt.getClaims(), ClientJwtClaim.class);
}
// Note: use this method to do payload simple verification.
public static String getAccountId(String jwtToken) {
ClientJwtClaim claim;
try {
String[] parts = jwtToken.split("\\.");
// Note: We need to use java.util.Base64's getUrlDecoder, instead of org.bouncycastle.util.encoders.Base64.
// b/c here the encoded string is coming from http request.
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
ObjectMapper objectMapper = new ObjectMapper();
claim = objectMapper.readValue(payload, ClientJwtClaim.class);
} catch (Exception e) {
log.error(
"getAccountId() failed, could not extract account id from JWT Bearer token, {}, error: {}",
e.getClass(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_BAD_JWT_BEARER_TOKEN);
}
String accountId = claim.getAccountId();
if (StringUtils.isBlank(accountId)) {
log.error("Account id is missing from JWT Bearer token");
throw new StateApiException(StateApiErrorCode.E_ACCOUNT_ID_MISSING_IN_JWT_TOKEN);
} else {
try {
UUID.fromString(claim.getAuthorizationCode());
} catch (IllegalArgumentException e) {
log.error("Authorization code is not valid: {}", claim.getAuthorizationCode());
throw new StateApiException(StateApiErrorCode.E_AUTHORIZATION_CODE_INVALID_FORMAT);
}
}
return accountId;
}
}

View file

@ -0,0 +1,38 @@
package gov.irs.directfile.stateapi.events;
import java.time.Instant;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Event {
@NotNull private final Instant timestamp = Instant.now();
@NotNull private final EventStatus eventStatus;
@NotNull private final EventId eventId;
@NotNull private final String responseStatusCode;
@NotNull private String taxPeriod;
@NotNull private String userType;
@NotNull private String remoteAddress;
@NotNull private String stateId;
@NotNull private String taxReturnId;
private final String eventErrorMessage;
private EventDetail detail;
@NotNull @Builder.Default
private String eventType = "STATE_API";
@NotNull @Builder.Default
private final boolean cyberOnly = true;
}

View file

@ -0,0 +1,29 @@
package gov.irs.directfile.stateapi.events;
import java.util.HashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
@Getter
@Setter
public class EventDetail {
private HashMap<String, String> detailMap;
public EventDetail() {
detailMap = new HashMap<>();
}
public void addDetail(String key, String val) {
if (StringUtils.isNotEmpty(key) && StringUtils.isNotEmpty(val)) {
detailMap.put(key, val);
}
}
public Map<String, String> getDetailMap() {
return detailMap;
}
}

View file

@ -0,0 +1,7 @@
package gov.irs.directfile.stateapi.events;
public enum EventId {
CREATE_AUTHORIZATION_CODE,
EXPORT_TAX_RETURN,
CREATE_AUTHORIZATION_TOKEN
}

View file

@ -0,0 +1,17 @@
package gov.irs.directfile.stateapi.events;
public enum EventStatus {
SUCCESS("00"),
FAILURE("01");
private final String value;
EventStatus(String value) {
this.value = value;
}
@Override
public String toString() {
return value;
}
}

View file

@ -0,0 +1,6 @@
package gov.irs.directfile.stateapi.events;
public enum UserType {
REGT,
SYS,
}

View file

@ -0,0 +1,21 @@
package gov.irs.directfile.stateapi.exception;
import lombok.Getter;
import gov.irs.directfile.error.StateApiErrorCode;
@Getter
public class StateApiException extends RuntimeException {
private final StateApiErrorCode errorCode;
public StateApiException(StateApiErrorCode errorCode) {
super(errorCode.name());
this.errorCode = errorCode;
}
public StateApiException(StateApiErrorCode errorCode, Throwable e) {
super(errorCode.name(), e);
this.errorCode = errorCode;
}
}

View file

@ -0,0 +1,14 @@
package gov.irs.directfile.stateapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class StateApiExceptionHandler {
@ExceptionHandler(StateApiException.class)
public ResponseEntity<String> handleStateApiException(StateApiException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View file

@ -0,0 +1,15 @@
package gov.irs.directfile.stateapi.exception;
import gov.irs.directfile.error.StateApiErrorCode;
public class StateApiExportedFactsDisabledException extends StateApiException {
public StateApiExportedFactsDisabledException() {
super(StateApiErrorCode.E_EXPORTED_FACTS_DISABLED);
}
@Override
public String getMessage() {
return getErrorCode().name() + "Encountered unexpected configuration mismatch - exported facts are disabled in"
+ " the backend api, but not in the state-api";
}
}

View file

@ -0,0 +1,10 @@
package gov.irs.directfile.stateapi.exception;
import gov.irs.directfile.error.StateApiErrorCode;
public class StateNotExistException extends StateApiException {
public StateNotExistException(StateApiErrorCode errorCode) {
super(errorCode);
}
}

View file

@ -0,0 +1,3 @@
package gov.irs.directfile.stateapi.model;
public record AesGcmEncryptionResult(byte[] ciphertext, byte[] authenticationTag) {}

View file

@ -0,0 +1,42 @@
package gov.irs.directfile.stateapi.model;
import java.util.UUID;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@JsonInclude(JsonInclude.Include.NON_NULL)
@AllArgsConstructor
@NoArgsConstructor
@Data
public class AuthCodeRequest {
@NotNull private UUID taxReturnUuid;
private String tin;
@NotNull private int taxYear;
@NotBlank
private String stateCode;
@NotBlank
private String submissionId;
@JsonIgnore
public static final String docsExampleObject =
"""
{
"taxReturnUuid": "ae019609-99e0-4ef5-85bb-ad90dc302e70",
"tin": "123456789",
"taxYear": 2022,
"stateCode": "DC",
"submissionId":"12345678901234567890"
}
""";
}

View file

@ -0,0 +1,58 @@
package gov.irs.directfile.stateapi.model;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Timestamp;
import java.util.UUID;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.util.encoders.Hex;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.exception.StateApiException;
@Data
@Table(name = "authorization_code")
@Entity
@Slf4j
@SuppressWarnings("PMD.PreserveStackTrace")
public class AuthorizationCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private UUID taxReturnUuid;
@NotBlank
private String authorizationCode;
@NotNull private int taxYear;
@NotNull private Timestamp expiresAt;
@NotBlank
private String stateCode;
private String submissionId;
public void setAuthorizationCode(UUID code) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(code.toString().getBytes(StandardCharsets.UTF_8));
this.authorizationCode = new String(Hex.encode(hash), StandardCharsets.UTF_8);
} catch (NoSuchAlgorithmException e) {
log.error("setAuthorizationCode() failed to hash UUID, could not find algorithm");
throw new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
}
}
}

View file

@ -0,0 +1,15 @@
package gov.irs.directfile.stateapi.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ClientJwtClaim {
@JsonProperty("iss")
private String accountId;
@JsonProperty("sub")
private String authorizationCode;
}

View file

@ -0,0 +1,4 @@
package gov.irs.directfile.stateapi.model;
public record EncryptData(
String encodedSecret, String encodedIV, String encodedAndEncryptedData, String encodedAuthenticationTag) {}

View file

@ -0,0 +1,13 @@
package gov.irs.directfile.stateapi.model;
import lombok.Builder;
import lombok.Data;
import gov.irs.directfile.error.StateApiErrorCode;
@Data
@Builder
public class ErrorResponse {
private StateApiErrorCode errorCode;
private String errorMessage;
}

View file

@ -0,0 +1,38 @@
package gov.irs.directfile.stateapi.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ExportResponse {
@NotNull private String status;
private String taxReturn;
private String error;
@JsonIgnore
public static final String docsExampleObjectSuccess =
"""
{
"status": "success",
"taxReturn": "encoded-encrypted-data"
}
""";
public static final String docsExampleObjectError =
"""
{
"status": "error",
"error": "E_AUTHORIZATION_CODE_EXPIRED"
}
""";
}

View file

@ -0,0 +1,7 @@
package gov.irs.directfile.stateapi.model;
import java.util.Map;
// TODO: Consolidate exported facts related classes/records between backend and state-api
// in shared data-models dependency https://git.irslabs.org/irslabs-prototypes/direct-file/-/issues/9570
public record GetStateExportedFactsResponse(Map<String, Object> exportedFacts) {}

View file

@ -0,0 +1,16 @@
package gov.irs.directfile.stateapi.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class StateAndAuthCode {
private String authorizationCode;
private String stateCode;
}

View file

@ -0,0 +1,30 @@
package gov.irs.directfile.stateapi.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Table(name = "state_language")
public class StateLanguage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull private Long stateProfileId;
/**
* The language code that DF understands, e.g. `es` or `en`
*/
@NotBlank
private String dfLanguageCode;
/**
* The corresponding language code that the state uses, e.g. `en`, `eng`, `english`, etc
*/
@NotBlank
private String stateLanguageCode;
}

View file

@ -0,0 +1,50 @@
package gov.irs.directfile.stateapi.model;
import java.time.OffsetDateTime;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Table(name = "state_profile")
public class StateProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String accountId;
@NotBlank
private String stateCode;
@NotBlank
private String taxSystemName;
@NotBlank
private String landingUrl;
private String defaultRedirectUrl;
private String departmentOfRevenueUrl;
private String filingRequirementsUrl;
private String transferCancelUrl;
private String waitingForAcceptanceCancelUrl;
private String certLocation;
@NotNull private Boolean acceptedOnly;
private OffsetDateTime certExpirationDate;
private String customFilingDeadline;
@NotNull private Boolean archived;
}

View file

@ -0,0 +1,27 @@
package gov.irs.directfile.stateapi.model;
import java.time.Instant;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Table(name = "state_redirect")
public class StateRedirect {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull private Long stateProfileId;
@NotBlank
private String redirectUrl;
@NotNull private Instant createdAt;
private Instant expiresAt;
}

View file

@ -0,0 +1,3 @@
package gov.irs.directfile.stateapi.model;
public record TaxReturnStatus(String status, boolean exists) {}

View file

@ -0,0 +1,23 @@
package gov.irs.directfile.stateapi.model;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.USE_DEFAULTS)
public class TaxReturnToExport {
@NotNull private String status;
private String submissionId;
private String xml;
private Map<String, Object> directFileData;
}

View file

@ -0,0 +1,3 @@
package gov.irs.directfile.stateapi.model;
public record TaxReturnXml(String status, String submissionId, String xml) {}

View file

@ -0,0 +1,13 @@
package gov.irs.directfile.stateapi.repository;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.model.AuthorizationCode;
@Repository
public interface AuthorizationCodeRepository extends R2dbcRepository<AuthorizationCode, Integer> {
Mono<AuthorizationCode> getByAuthorizationCode(@Param("authorizationCode") String authDigest);
}

View file

@ -0,0 +1,67 @@
package gov.irs.directfile.stateapi.repository;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.configuration.DirectFileEndpointProperties;
import gov.irs.directfile.stateapi.exception.StateApiException;
import gov.irs.directfile.stateapi.exception.StateApiExportedFactsDisabledException;
import gov.irs.directfile.stateapi.model.GetStateExportedFactsResponse;
import gov.irs.directfile.stateapi.model.TaxReturnStatus;
@Service
@Slf4j
public class DirectFileBackendService {
private final WebClient webClient;
private final String baseUri;
public DirectFileBackendService(DirectFileEndpointProperties dfEndpointProperties) {
webClient = WebClient.builder()
.defaultStatusHandler(
HttpStatusCode::isError, resp -> resp.createException().flatMap(e -> {
log.error(
"DirectFileBackendService failed, {}, error: {}",
e.getClass().getName(),
e.getMessage());
return Mono.just(new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR));
}))
.baseUrl(dfEndpointProperties.getBackendUrl())
.build();
baseUri = dfEndpointProperties.getBackendContextPath() + "/" + dfEndpointProperties.getBackendApiVersion();
}
public Mono<GetStateExportedFactsResponse> getExportedFacts(
String submissionId, String stateCode, String accountId) {
String efUri = baseUri + "/state-api/state-exported-facts/{submissionId}";
return webClient
.get()
.uri(uriBuilder -> uriBuilder
.path(efUri)
.queryParam("stateCode", stateCode)
.queryParam("accountId", accountId)
.build(submissionId))
.retrieve()
.onStatus(
HttpStatus.METHOD_NOT_ALLOWED::equals,
clientResponse -> Mono.error(new StateApiExportedFactsDisabledException()))
.bodyToMono(GetStateExportedFactsResponse.class);
}
public Mono<TaxReturnStatus> getStatus(int taxYear, UUID taxReturnId, String submissionId) {
String statusUri = baseUri + "/state-api/status/{taxFilingYear}/{taxReturnId}/{submissionId}";
return webClient
.get()
.uri(uriBuilder -> uriBuilder.path(statusUri).build(taxYear, taxReturnId, submissionId))
.retrieve()
.bodyToMono(TaxReturnStatus.class);
}
}

View file

@ -0,0 +1,17 @@
package gov.irs.directfile.stateapi.repository;
import java.util.UUID;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.model.TaxReturnStatus;
@SuppressWarnings("PMD.UnnecessaryModifier")
public interface DirectFileClient {
static String STATUS_ACCEPTED = "accepted";
static String STATUS_REJECTED = "rejected";
static String STATUS_PENDING = "pending";
static String STATUS_ERROR = "error";
Mono<TaxReturnStatus> getStatus(int taxYear, UUID taxReturnId, String submissionId);
}

View file

@ -0,0 +1,49 @@
package gov.irs.directfile.stateapi.repository;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.configuration.DirectFileEndpointProperties;
import gov.irs.directfile.stateapi.model.TaxReturnStatus;
@Component
@ConditionalOnProperty(name = "direct-file.status.mock", havingValue = "false", matchIfMissing = true)
@Slf4j
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
public class DirectFileClientImpl implements DirectFileClient {
private DirectFileBackendService directFileBackendService;
public DirectFileClientImpl(DirectFileEndpointProperties dfEndpointProperties) {
directFileBackendService = new DirectFileBackendService(dfEndpointProperties);
}
@Override
public Mono<TaxReturnStatus> getStatus(int taxYear, UUID taxReturnId, String submissionId) {
return directFileBackendService
.getStatus(taxYear, taxReturnId, submissionId)
.map(ts -> {
if (!ts.exists()) {
log.warn(
"getStatus: E_TAX_RETURN_NOT_FOUND (possibly due to replication delay across regions, expecting retry from user). taxReturnId={}, submissionId={}, tax year={}",
taxReturnId,
submissionId,
taxYear);
}
return new TaxReturnStatus(ts.status().toLowerCase(), ts.exists());
})
.doOnError(e -> {
log.error(
"getStatus() failed for taxYear={}, taxReturnId={}, submissionId={}. Exception: {}. Error: {}",
taxYear,
taxReturnId.toString(),
submissionId,
e.getClass().getName(),
e.getMessage());
});
}
}

View file

@ -0,0 +1,26 @@
package gov.irs.directfile.stateapi.repository;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.model.TaxReturnStatus;
@Component
@Slf4j
@ConditionalOnProperty(name = "direct-file.status.mock", havingValue = "true", matchIfMissing = false)
public class DirectFileClientMock implements DirectFileClient {
@Override
public Mono<TaxReturnStatus> getStatus(int taxYear, UUID taxReturnId, String submissionId) {
log.info("Enter Mock getStatus submissionId={}, taxReturnId={}", submissionId, taxReturnId);
String idString = taxReturnId.toString();
if (idString.startsWith("a")) return Mono.just(new TaxReturnStatus(STATUS_ACCEPTED, true));
else if (idString.startsWith("b")) return Mono.just(new TaxReturnStatus(STATUS_REJECTED, true));
else return Mono.just(new TaxReturnStatus(STATUS_PENDING, true));
}
}

View file

@ -0,0 +1,13 @@
package gov.irs.directfile.stateapi.repository;
import java.io.InputStream;
import java.util.UUID;
import reactor.core.publisher.Mono;
@SuppressWarnings("PMD.UnnecessaryModifier")
public interface StateApiS3Client {
public Mono<InputStream> getCert(String certUrl);
public Mono<String> getTaxReturnXml(int taxYear, UUID taxReturnId, String submissionId);
}

View file

@ -0,0 +1,179 @@
package gov.irs.directfile.stateapi.repository;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.encryption.s3.S3AsyncEncryptionClient;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.configuration.S3ConfigurationProperties;
import gov.irs.directfile.stateapi.configuration.XmlSanitizedConfigurationProperties;
import gov.irs.directfile.stateapi.exception.StateApiException;
@Component
@Slf4j
@SuppressWarnings({"PMD.AvoidReassigningLoopVariables"})
public class StateApiS3ClientImpl implements StateApiS3Client {
private final S3AsyncClient s3Client;
private final S3AsyncEncryptionClient s3EncryptionClient;
private final S3ConfigurationProperties s3ConfigurationProperties;
private final XmlSanitizedConfigurationProperties xmlSanitizedConfigurationProperties;
private List<Pattern> excludedPatterns = new ArrayList<>();
@Autowired
public StateApiS3ClientImpl(
S3AsyncClient s3Client,
S3AsyncEncryptionClient s3EncryptionClient,
S3ConfigurationProperties s3ConfigurationProperties,
XmlSanitizedConfigurationProperties xmlSanitizedConfigurationProperties) {
this.s3Client = s3Client;
this.s3EncryptionClient = s3EncryptionClient;
this.s3ConfigurationProperties = s3ConfigurationProperties;
this.xmlSanitizedConfigurationProperties = xmlSanitizedConfigurationProperties;
setExcludedPatterns();
}
private void setExcludedPatterns() {
log.info("xml headers allowed: {}", xmlSanitizedConfigurationProperties.getAllowedHeaders());
var excludedTags = xmlSanitizedConfigurationProperties.getExcludedTags();
log.info("xml tags excluded: {}", excludedTags);
if (excludedTags != null) {
for (String header : excludedTags) {
String formattedHeader = header.trim();
String regex = "<" + formattedHeader + ">[\\s\\S]*?</" + formattedHeader + ">";
excludedPatterns.add(Pattern.compile(regex));
}
}
// remove all blank lines caused by xml node manipulation
excludedPatterns.add(Pattern.compile("(?m)^[ \\t]*\\r?\\n"));
}
@Override
public Mono<InputStream> getCert(String certName) {
log.info("enter getCert()...for cert {}", certName);
String s3Prefix = s3ConfigurationProperties.getPrefix();
String objectKey = (StringUtils.isNotBlank(s3Prefix) ? s3Prefix + "/" : "") + certName;
log.info("checking for cert at path {}", objectKey);
return getUnencryptedS3Object(
s3ConfigurationProperties.getCertBucketName(),
objectKey,
StateApiErrorCode.E_CERTIFICATE_NOT_FOUND)
.map(s -> new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)));
}
@Override
public Mono<String> getTaxReturnXml(int taxYear, UUID taxReturnId, String submissionId) {
String objectKey = generateSubmissionLocationObjectKey(taxYear, taxReturnId, submissionId);
return getEncryptedS3Object(
s3ConfigurationProperties.getTaxReturnXmlBucketName(),
objectKey,
StateApiErrorCode.E_TAX_RETURN_NOT_FOUND)
.flatMap(xmlString -> {
log.info("getSanitizedXml for: fillingYear={}, taxReturnId={}", taxYear, taxReturnId);
try {
String sanitizedXml = getXmlString(xmlString);
return Mono.just(sanitizedXml);
} catch (Exception e) {
log.error("getSanitizedXml failed, {}, {}", e.getClass().getName(), e.getMessage(), e);
return Mono.error(new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR));
}
});
}
private String getXmlString(String ignoredXmlString) {
return "<xml>";
}
private String generateSubmissionLocationObjectKey(int taxFilingYear, UUID taxReturnId, String submissionId) {
String objectPath = taxFilingYear + "/taxreturns/" + taxReturnId + "/submissions/" + submissionId + ".xml";
String s3Prefix = s3ConfigurationProperties.getPrefix();
if (StringUtils.isNotBlank(s3Prefix)) {
return s3Prefix + "/" + objectPath;
}
return objectPath;
}
private Mono<String> getEncryptedS3Object(String theBucketName, String objectKey, StateApiErrorCode ec) {
return getS3Object(theBucketName, objectKey, ec, s3EncryptionClient);
}
private Mono<String> getUnencryptedS3Object(String theBucketName, String objectKey, StateApiErrorCode ec) {
return getS3Object(theBucketName, objectKey, ec, s3Client);
}
private Mono<String> getS3Object(
String theBucketName, String objectKey, StateApiErrorCode ec, S3AsyncClient s3Client) {
log.info("enter getS3Object()...for bucket: {}, key: {}", theBucketName, objectKey);
GetObjectRequest objectRequest =
GetObjectRequest.builder().bucket(theBucketName).key(objectKey).build();
CompletableFuture<ResponseBytes<GetObjectResponse>> futureGet =
s3Client.getObject(objectRequest, AsyncResponseTransformer.toBytes());
return Mono.fromFuture(() -> futureGet)
.switchIfEmpty(Mono.defer(() -> {
log.error(
"getS3Object() failed, cannot find s3 object, bucket: {}, key: {}",
theBucketName,
objectKey);
return Mono.error(new StateApiException(ec));
}))
.onErrorMap(NoSuchKeyException.class, e -> {
log.error(
"getS3Object() failed, cannot find s3 object, bucket: {}, key: {}",
theBucketName,
objectKey);
return new StateApiException(ec);
})
.onErrorMap(e -> !(e instanceof StateApiException), e -> {
log.error(
"getS3Object() failed, bucket: {}, key: {}, {}, error: {}",
theBucketName,
objectKey,
e.getClass().getName(),
e.getMessage());
return new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
})
.map(responseBytes -> {
ByteBuffer byteBuffer = responseBytes.asByteBuffer();
byte[] byteArray = new byte[byteBuffer.remaining()];
byteBuffer.get(byteArray);
log.info(
"object retrieved from S3, length: {}, bucket: {}, key: {}",
byteArray.length,
theBucketName,
objectKey);
return new String(byteArray, StandardCharsets.UTF_8);
});
}
}

View file

@ -0,0 +1,10 @@
package gov.irs.directfile.stateapi.repository;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import gov.irs.directfile.stateapi.model.StateLanguage;
public interface StateLanguageRepository extends R2dbcRepository<StateLanguage, Long> {
Flux<StateLanguage> getAllByStateProfileId(Long stateProfileId);
}

View file

@ -0,0 +1,14 @@
package gov.irs.directfile.stateapi.repository;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.model.StateProfile;
@Repository
public interface StateProfileRepository extends R2dbcRepository<StateProfile, Integer> {
Mono<StateProfile> getByAccountId(String accountId);
Mono<StateProfile> getByStateCode(String stateCode);
}

View file

@ -0,0 +1,10 @@
package gov.irs.directfile.stateapi.repository;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import gov.irs.directfile.stateapi.model.StateRedirect;
public interface StateRedirectRepository extends R2dbcRepository<StateRedirect, Long> {
Flux<StateRedirect> getAllByStateProfileId(Long stateProfileId);
}

View file

@ -0,0 +1,10 @@
package gov.irs.directfile.stateapi.repository.facts;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.model.GetStateExportedFactsResponse;
public interface ExportedFactsClient {
Mono<GetStateExportedFactsResponse> getExportedFacts(String submissionId, String stateCode, String accountId);
}

View file

@ -0,0 +1,41 @@
package gov.irs.directfile.stateapi.repository.facts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.configuration.DirectFileEndpointProperties;
import gov.irs.directfile.stateapi.model.GetStateExportedFactsResponse;
import gov.irs.directfile.stateapi.repository.DirectFileBackendService;
@Component
@ConditionalOnProperty(name = "direct-file.exported-facts.mock", havingValue = "false", matchIfMissing = true)
@Slf4j
public class ExportedFactsClientImpl implements ExportedFactsClient {
private DirectFileBackendService directFileBackendService;
public ExportedFactsClientImpl(DirectFileEndpointProperties dfEndpointProperties) {
directFileBackendService = new DirectFileBackendService(dfEndpointProperties);
}
@Override
public Mono<GetStateExportedFactsResponse> getExportedFacts(
String submissionId, String stateCode, String accountId) {
return directFileBackendService
.getExportedFacts(submissionId, stateCode, accountId)
.doOnSuccess(ef -> log.info(
"getExportedFacts runs successfully for accountId={}, submissionId={}",
accountId,
submissionId))
.doOnError(e -> {
log.error(
"getExportedFacts() failed for accountId={}, submissionId={}, {}, error: {}",
accountId,
submissionId,
e.getClass().getName(),
e.getMessage());
});
}
}

View file

@ -0,0 +1,204 @@
package gov.irs.directfile.stateapi.repository.facts;
import java.util.ArrayList;
import java.util.HashMap;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.model.GetStateExportedFactsResponse;
@Component
@ConditionalOnProperty(name = "direct-file.exported-facts.mock", havingValue = "true", matchIfMissing = false)
@Slf4j
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
public class ExportedFactsClientMock implements ExportedFactsClient {
private static final String FILERS = "filers";
private static final String FAMILY_AND_HH = "familyAndHousehold";
private static final String FIRST_NAME = "firstName";
public static final String MIDDLE_INITIAL = "middleInitial";
public static final String LAST_NAME = "lastName";
public static final String SUFFIX = "suffix";
public static final String DATE_OF_BIRTH = "dateOfBirth";
public static final String RELATIONSHIP = "relationship";
public static final String ELIGIBLE_DEPENDENT = "eligibleDependent";
public static final String IS_CLAIMED_DEPENDENT = "isClaimedDependent";
public static final String IS_PRIMARY_FILER = "isPrimaryFiler";
private static final String RESIDENCY_DURATION = "residencyDuration";
private static final String MONTH_LIVE_WITHTP_IN_US = "monthsLivedWithTPInUS";
private static final String SSN_NOT_VALID_FOR_EMPLOYMENT = "ssnNotValidForEmployment";
private static final String QUALIFYING_CHILD = "qualifyingChild";
private static final String EDUCATOR_EXPENSES = "educatorExpenses";
private static final String HSA_TOTAL_DEDUCTIBLE_AMOUNT = "hsaTotalDeductibleAmount";
private static final String INTEREST_REPORTS_TOTAL = "interestReportsTotal";
private static final String FORM_1099Gs_TOTAL = "form1099GsTotal";
private static final String IS_STUDENT = "isStudent";
private static final String IS_DISABLED = "isDisabled";
// 1099-Int
private static final String TIN_NUM = "tin";
private static final String RECIPIENT_TIN = "recipientTin";
private static final String INTEREST_REPORT = "interestReports";
private static final String AMT_1099 = "1099Amount";
private static final String TAX_EXEMPT_INTEREST = "taxExemptInterest";
private static final String INTEREST_GOVERNMENT_BONDS = "interestOnGovernmentBonds";
private static final String HAS_1099 = "has1099";
private static final String NO_1099_AMOUNT = "no1099Amount";
private static final String PAYER_KEY = "payer";
private static final String PAYER_TIN = "payerTin";
private static final String TAX_WITHHELD = "taxWithheld";
private static final String CUSIP_NO = "taxExemptAndTaxCreditBondCusipNo";
// 1099-G
private static final String FORM_1099Gs = "form1099Gs";
private static final String HAS_1099G = "has1099";
private static final String AMT_1099G = "amount";
private static final String FED_TAX_WITHHELD = "federalTaxWithheld";
private static final String STATE_ID_NUM = "stateIdNumber";
private static final String STATE_TAX_WITHHELD = "stateTaxWithheld";
private static final String AMT_PAID_FOR_BENEFITS = "amountPaidBackForBenefitsInTaxYear";
private static final String SCHEDULE_EIC_LINE4A_YES = "scheduleEicLine4aYes";
private static final String SCHEDULE_EIC_LINE4A_NO = "scheduleEicLine4aNo";
private static final String SCHEDULE_EIC_LINE4B_YES = "scheduleEicLine4bYes";
private static final String HOH_QUALIFYING_PERSON = "hohQualifyingPerson";
// FormW2s
private static final String FORM_W2s = "formW2s";
private static final String UNION_DUES_AMT = "unionDuesAmount";
private static final String BOX14_NJ_UIHCWD = "BOX14_NJ_UIHCWD";
private static final String BOX14_NJ_UIWFSWF = "BOX14_NJ_UIWFSWF";
// socialSecurityReports
private static final String SOCIAL_SECURITY_RPT = "socialSecurityReports";
private static final String NET_BENEFITS = "netBenefits";
private static final String FORM_TYPE = "formType";
@Override
public Mono<GetStateExportedFactsResponse> getExportedFacts(
String submissionId, String stateCode, String accountId) {
log.info("Enter Mock getExportedFacts submissionId={}, accountId={}", submissionId, accountId);
var exportedFacts = new HashMap<String, Object>();
var filers = new ArrayList<HashMap<String, Object>>();
var filer1 = new HashMap<String, Object>();
filer1.put(FIRST_NAME, "Samuel");
filer1.put(MIDDLE_INITIAL, null);
filer1.put(LAST_NAME, "Smith");
filer1.put(SUFFIX, "Jr");
filer1.put(DATE_OF_BIRTH, "1985-09-29");
filer1.put(IS_PRIMARY_FILER, Boolean.TRUE);
filer1.put(TIN_NUM, "100-01-1234");
filer1.put(SSN_NOT_VALID_FOR_EMPLOYMENT, false);
filer1.put(EDUCATOR_EXPENSES, "200.00");
filer1.put(HSA_TOTAL_DEDUCTIBLE_AMOUNT, "600.00");
filer1.put(INTEREST_REPORTS_TOTAL, "3000.00");
filer1.put(FORM_1099Gs_TOTAL, "15000.00");
filer1.put(IS_STUDENT, false);
filer1.put(IS_DISABLED, false);
filers.add(filer1);
var filer2 = new HashMap<String, Object>();
filer2.put(FIRST_NAME, "Judy");
filer2.put(MIDDLE_INITIAL, null);
filer2.put(LAST_NAME, "Johnson");
filer2.put(SUFFIX, null);
filer2.put(DATE_OF_BIRTH, "1985-10-18");
filer2.put(IS_PRIMARY_FILER, Boolean.FALSE);
filer2.put(TIN_NUM, "100-02-1234");
filer2.put(SSN_NOT_VALID_FOR_EMPLOYMENT, false);
filer2.put(EDUCATOR_EXPENSES, "100.00");
filer2.put(HSA_TOTAL_DEDUCTIBLE_AMOUNT, "500.00");
filer2.put(INTEREST_REPORTS_TOTAL, "300.00");
filer2.put(FORM_1099Gs_TOTAL, "1500.00");
filer2.put(IS_STUDENT, false);
filer2.put(IS_DISABLED, false);
filers.add(filer2);
exportedFacts.put(FILERS, filers);
var familyAndHousehold = new ArrayList<HashMap<String, Object>>();
var person1 = new HashMap<String, Object>();
person1.put(FIRST_NAME, "Sammy");
person1.put(MIDDLE_INITIAL, null);
person1.put(LAST_NAME, "Smith");
person1.put(SUFFIX, "I");
person1.put(DATE_OF_BIRTH, "2013-01-21");
person1.put(RELATIONSHIP, "biologicalChild");
person1.put(ELIGIBLE_DEPENDENT, true);
person1.put(IS_CLAIMED_DEPENDENT, true);
person1.put(TIN_NUM, "200-01-1234");
person1.put(RESIDENCY_DURATION, "allYear");
person1.put(MONTH_LIVE_WITHTP_IN_US, "twelve");
person1.put(SCHEDULE_EIC_LINE4A_YES, true);
person1.put(SCHEDULE_EIC_LINE4A_NO, false);
person1.put(SCHEDULE_EIC_LINE4B_YES, false);
person1.put(HOH_QUALIFYING_PERSON, true);
person1.put(SSN_NOT_VALID_FOR_EMPLOYMENT, false);
person1.put(QUALIFYING_CHILD, true);
familyAndHousehold.add(person1);
exportedFacts.put(FAMILY_AND_HH, familyAndHousehold);
var intReports = new ArrayList<HashMap<String, Object>>();
var intRpt = new HashMap<String, Object>();
intRpt.put(HAS_1099, Boolean.TRUE);
intRpt.put(AMT_1099, "800.00");
intRpt.put(INTEREST_GOVERNMENT_BONDS, "300");
intRpt.put(TAX_EXEMPT_INTEREST, "200.00");
intRpt.put(RECIPIENT_TIN, "123-45-6789");
intRpt.put(NO_1099_AMOUNT, null);
intRpt.put(PAYER_KEY, "JPM Bank");
intRpt.put(PAYER_TIN, "01-1234567");
intRpt.put(TAX_WITHHELD, "120");
intRpt.put(CUSIP_NO, "01234567A");
intReports.add(intRpt);
exportedFacts.put(INTEREST_REPORT, intReports);
// 1099-G
var form1099Gs = new ArrayList<HashMap<String, Object>>();
var form1099G = new HashMap<String, Object>();
form1099G.put(HAS_1099G, Boolean.TRUE);
form1099G.put(RECIPIENT_TIN, "123-45-6789");
form1099G.put(PAYER_KEY, "State of california");
form1099G.put(PAYER_TIN, "321-54-9876");
form1099G.put(AMT_1099G, "100.00");
form1099G.put(FED_TAX_WITHHELD, "20.00");
form1099G.put(STATE_ID_NUM, "123456");
form1099G.put(STATE_TAX_WITHHELD, "10.00");
form1099G.put(AMT_PAID_FOR_BENEFITS, "25.00");
form1099Gs.add(form1099G);
exportedFacts.put(FORM_1099Gs, form1099Gs);
// formW2
var formW2s = new ArrayList<HashMap<String, Object>>();
var formW2 = new HashMap<String, Object>();
formW2.put(UNION_DUES_AMT, "40.00");
formW2.put(BOX14_NJ_UIHCWD, "101.00");
formW2.put(BOX14_NJ_UIWFSWF, "101.00");
formW2s.add(formW2);
exportedFacts.put(FORM_W2s, formW2s);
// socialSecurityReports
var ssRpts = new ArrayList<HashMap<String, Object>>();
var ssRpt = new HashMap<String, Object>();
ssRpt.put(RECIPIENT_TIN, "123-45-6789");
ssRpt.put(NET_BENEFITS, "21000.00");
ssRpt.put(FORM_TYPE, "SSA-1099");
ssRpts.add(ssRpt);
exportedFacts.put(SOCIAL_SECURITY_RPT, ssRpts);
var response = new GetStateExportedFactsResponse(exportedFacts);
return Mono.just(response);
}
}

View file

@ -0,0 +1,162 @@
package gov.irs.directfile.stateapi.service;
import java.security.PublicKey;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.dto.StateProfileDTO;
import gov.irs.directfile.stateapi.exception.StateApiException;
import gov.irs.directfile.stateapi.exception.StateNotExistException;
import gov.irs.directfile.stateapi.model.StateLanguage;
import gov.irs.directfile.stateapi.model.StateProfile;
import gov.irs.directfile.stateapi.model.StateRedirect;
import gov.irs.directfile.stateapi.repository.StateApiS3Client;
import gov.irs.directfile.stateapi.repository.StateLanguageRepository;
import gov.irs.directfile.stateapi.repository.StateProfileRepository;
import gov.irs.directfile.stateapi.repository.StateRedirectRepository;
@Component
@Slf4j
@SuppressWarnings("PMD.PreserveStackTrace")
public class CachedDataService {
@Autowired
private StateApiS3Client s3Client;
@Autowired
private StateProfileRepository spRepo;
@Autowired
private StateRedirectRepository srRepo;
@Autowired
private StateLanguageRepository slRepo;
@Value("${spring.cache.TTL-minutes: 120}")
private long cacheTTL;
// Note: We are applying cache of cache to a Mono. The native Caffeine cache's 'expireAfterAccess' won't take
// effect. For the sake of simplicity, we periodically evict the caches.
@CacheEvict(
value = {"publicKeyCache", "stateProfileCache"},
allEntries = true)
@Scheduled(fixedRateString = "${spring.cache.TTL-minutes}", timeUnit = TimeUnit.MINUTES)
public void emptyCaches() {
log.info("caches (publicKeyCache, stateProfileCache) were evicted after {} minutes", cacheTTL);
}
// NOTE: the cert is cached and expiration won't apply during the cache duration
@Cacheable(cacheNames = "publicKeyCache", key = "#certName")
public Mono<PublicKey> retrievePublicKeyFromCert(String certName, OffsetDateTime enforcedExpirationDate) {
log.info("enter retrievePublicKeyFromCert()...for {}", certName);
return s3Client.getCert(certName)
.flatMap(is -> {
X509Certificate cert;
try {
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
cert = (X509Certificate) certFactory.generateCertificate(is);
} catch (CertificateException e) {
log.error(
"retrievePublicKeyFromCert failed, {}, {}",
e.getClass().getName(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
}
// Check if the certificate is expired
Date currentDate = new Date();
if (currentDate.after(cert.getNotAfter())) {
log.error("The certificate {} has expired", certName);
throw new StateApiException(StateApiErrorCode.E_CERTIFICATE_EXPIRED);
}
// check IRS enforced expiration date
if (enforcedExpirationDate != null) {
OffsetDateTime currentDateTime = OffsetDateTime.now(ZoneOffset.UTC);
if (currentDateTime.isAfter(enforcedExpirationDate)) {
log.error("The certificate {} has passed the IRS enforced expiration date", certName);
throw new StateApiException(StateApiErrorCode.E_CERTIFICATE_EXPIRED);
}
}
return Mono.just(cert.getPublicKey());
})
.cache(); // This is the hack to make @Cacheable work #https://www.baeldung.com/spring-webflux-cacheable
}
@Cacheable(cacheNames = "stateProfileCache", key = "#accountId")
public Mono<StateProfile> getStateProfile(String accountId) {
log.info("enter getStateProfile()...accountId={}", accountId);
return spRepo.getByAccountId(accountId)
.switchIfEmpty(Mono.defer(() -> {
log.error(
"getStateProfile() failed, account id does not exist in state_profile table for account id: {}",
accountId);
return Mono.error(new StateApiException(StateApiErrorCode.E_ACCOUNT_ID_NOT_EXIST));
}))
.onErrorMap(e -> !(e instanceof StateApiException), e -> {
log.error(
"getStateProfile failed for account id: {}, {}, error: {}",
accountId,
e.getClass().getName(),
e.getMessage());
return new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
})
.cache();
}
@Cacheable(cacheNames = "stateProfileCache", key = "#stateCode")
public Mono<StateProfileDTO> getStateProfileByStateCode(String stateCode) {
log.info("enter getStateProfileByStateCode()...stateCode={}", stateCode);
return spRepo.getByStateCode(stateCode)
.flatMap(this::loadRelations)
.switchIfEmpty(Mono.defer(() -> {
log.info("No StateProfile returns, state code {} does not exist in state_profile table", stateCode);
return Mono.error(new StateNotExistException(StateApiErrorCode.E_STATE_NOT_EXIST));
}))
.onErrorMap(e -> !(e instanceof StateApiException), e -> {
log.error(
"getStateProfileByStateCode() failed for state code: {}, {}, error: {}",
stateCode,
e.getClass().getName(),
e.getMessage());
return new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
})
.cache();
}
private Mono<StateProfileDTO> loadRelations(final StateProfile stateProfile) {
var stateProfileId = stateProfile.getId();
// Load the redirect urls
Mono<List<StateRedirect>> redirectUrls =
srRepo.getAllByStateProfileId(stateProfileId).collectList();
// Load the languages
Mono<List<StateLanguage>> stateLanguages =
slRepo.getAllByStateProfileId(stateProfileId).collectList();
return redirectUrls
.zipWith(stateLanguages)
.map((urlsAndLanguagesTuple) -> new StateProfileDTO(
stateProfile, urlsAndLanguagesTuple.getT1(), urlsAndLanguagesTuple.getT2()));
}
}

View file

@ -0,0 +1,30 @@
package gov.irs.directfile.stateapi.service;
import java.util.Map;
import java.util.UUID;
import reactor.core.publisher.Mono;
import gov.irs.directfile.stateapi.dto.StateProfileDTO;
import gov.irs.directfile.stateapi.model.*;
public interface StateApiService {
Mono<UUID> createAuthorizationCode(AuthCodeRequest ac);
Mono<String> generateAuthorizationToken(AuthCodeRequest ac);
Mono<StateAndAuthCode> verifyJwtSignature(String jwt, String accountId);
Mono<AuthorizationCode> authorize(StateAndAuthCode stateAndAuthCode);
Mono<TaxReturnXml> retrieveTaxReturnXml(int taxYear, UUID taxReturnUuid, String submissionId);
Mono<EncryptData> encryptTaxReturn(TaxReturnToExport taxReturn, String accountId);
Mono<StateProfileDTO> lookupStateProfile(String stateCode);
Mono<StateProfile> getStateProfile(String accountId);
Mono<Map<String, Object>> retrieveExportedFacts(String submissionId, String stateCode, String accountId);
}

View file

@ -0,0 +1,437 @@
package gov.irs.directfile.stateapi.service;
import java.io.FileNotFoundException;
import java.security.PublicKey;
import java.security.Security;
import java.security.cert.CertificateExpiredException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.shaded.gson.Gson;
import com.nimbusds.jose.shaded.gson.GsonBuilder;
import io.micrometer.common.util.StringUtils;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.encoders.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import gov.irs.directfile.error.StateApiErrorCode;
import gov.irs.directfile.stateapi.authorization.AuthorizationTokenClaims;
import gov.irs.directfile.stateapi.authorization.AuthorizationTokenService;
import gov.irs.directfile.stateapi.configuration.CertificationOverrideProperties;
import gov.irs.directfile.stateapi.dto.StateProfileDTO;
import gov.irs.directfile.stateapi.encryption.JwtVerifier;
import gov.irs.directfile.stateapi.exception.StateApiException;
import gov.irs.directfile.stateapi.model.*;
import gov.irs.directfile.stateapi.repository.*;
import gov.irs.directfile.stateapi.repository.facts.ExportedFactsClient;
import static gov.irs.directfile.stateapi.encryption.Encryptor.*;
import static gov.irs.directfile.stateapi.repository.DirectFileClient.STATUS_ERROR;
import static gov.irs.directfile.stateapi.repository.DirectFileClient.STATUS_REJECTED;
@Service
@Slf4j
@SuppressWarnings({
"PMD.UnusedPrivateMethod",
"PMD.LiteralsFirstInComparisons",
"PMD.UselessParentheses",
"PMD.PreserveStackTrace"
})
public class StateApiServiceImpl implements StateApiService {
@Value("${authorization-code.expires-interval-seconds: 600}")
private int authorizationCodeExpiresInterval;
@Value("${direct-file.exported-facts.enabled: false}")
private boolean exported_facts_enabled;
@Autowired
private AuthorizationCodeRepository acRepo;
@Autowired
private AuthorizationTokenService authorizationTokenService;
@Autowired
private DirectFileClient dfClient;
@Autowired
private ExportedFactsClient efClient;
@Autowired
private CachedDataService cachedDS;
@Autowired
private ObjectMapper mapper;
@Autowired
private StateApiS3Client stateApiS3Client;
@Autowired
CertificationOverrideProperties certProperties;
@PostConstruct
private void init() {
Security.addProvider(new BouncyCastleProvider());
}
@Override
public Mono<UUID> createAuthorizationCode(AuthCodeRequest acq) {
log.info("enter createAuthorizationCode()...");
// The `dfClient.getStatus()` function returns the status along with an indicator of whether the XML exists in
// the S3 bucket. However, it's important to consider the issue of eventual consistency, which refers to the
// delay in propagating S3 objects. While we may verify the existence of the object in the west region, there's
// a possibility that when performing the `export-tax-return` operation, retrieval occurs from the east region,
// potentially resulting in a 'tax-return-not-found' error.
return getAcceptedOnlyFlag(acq.getStateCode())
.zipWith(
dfClient.getStatus(acq.getTaxYear(), acq.getTaxReturnUuid(), acq.getSubmissionId()),
(acceptedOnly, status) -> {
final boolean isAccepted =
status.status().equalsIgnoreCase(DirectFileClient.STATUS_ACCEPTED);
final boolean isPending = status.status().equalsIgnoreCase(DirectFileClient.STATUS_PENDING);
final boolean submissionOk =
status.exists() && (isAccepted || (!acceptedOnly && isPending));
if (submissionOk) {
final AuthorizationCode ac = new AuthorizationCode();
final UUID code = UUID.randomUUID();
ac.setAuthorizationCode(code);
ac.setTaxReturnUuid(acq.getTaxReturnUuid());
ac.setTaxYear(acq.getTaxYear());
Timestamp expireAt =
Timestamp.from(Instant.now().plusSeconds(authorizationCodeExpiresInterval));
ac.setExpiresAt(expireAt);
ac.setStateCode(acq.getStateCode());
ac.setSubmissionId(acq.getSubmissionId());
return acRepo.save(ac).thenReturn(code).onErrorMap(e -> {
log.error(
"createAuthorizationCode() failed for taxReturnId={}, submissionId={}, taxYear={}. Exception: {}. Error: {}",
acq.getTaxReturnUuid(),
acq.getSubmissionId(),
acq.getTaxYear(),
e.getClass().getName(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
});
} else {
final StateApiErrorCode errorCode;
if (!status.exists()) {
errorCode = StateApiErrorCode.E_TAX_RETURN_NOT_FOUND;
} else if (acceptedOnly) {
errorCode = StateApiErrorCode.E_TAX_RETURN_NOT_ACCEPTED;
} else {
errorCode = StateApiErrorCode.E_TAX_RETURN_NOT_ACCEPTED_OR_PENDING;
}
log.error(
"createAuthorizationCode() failed for taxReturnId={}, submissionId={}, taxYear={}. Error: {}",
acq.getTaxReturnUuid(),
acq.getSubmissionId(),
acq.getTaxYear(),
errorCode.name());
throw new StateApiException(errorCode);
}
})
.flatMap(uuidMono -> uuidMono.map(uuid -> uuid));
}
@Override
public Mono<String> generateAuthorizationToken(AuthCodeRequest acRequest) {
log.info("Generating authorization token");
// The `dfClient.getStatus()` function returns the status along with an indicator of whether the XML exists in
// the S3 bucket. However, it's important to consider the issue of eventual consistency, which refers to the
// delay in propagating S3 objects. While we may verify the existence of the object in the west region, there's
// a possibility that when performing the `export-tax-return` operation, retrieval occurs from the east region,
// potentially resulting in a 'tax-return-not-found' error.
return getAcceptedOnlyFlag(acRequest.getStateCode())
.zipWith(
dfClient.getStatus(
acRequest.getTaxYear(), acRequest.getTaxReturnUuid(), acRequest.getSubmissionId()),
(acceptedOnly, status) -> {
var isAccepted = status.status().equalsIgnoreCase(DirectFileClient.STATUS_ACCEPTED);
var isPending = status.status().equalsIgnoreCase(DirectFileClient.STATUS_PENDING);
boolean submissionOk = status.exists() && (isAccepted || (!acceptedOnly && isPending));
if (submissionOk) {
return authorizationTokenService
.generateAndEncrypt(
mapper.convertValue(acRequest, AuthorizationTokenClaims.class))
.onErrorMap(e -> {
log.error(
"generateAuthorizationToken() failed for taxReturnId={}, submissionId={}, taxYear={}. Exception: {}. Error: {}",
acRequest.getTaxReturnUuid(),
acRequest.getSubmissionId(),
acRequest.getTaxYear(),
e.getClass().getName(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
});
} else {
StateApiErrorCode errorCode;
if (!status.exists()) {
errorCode = StateApiErrorCode.E_TAX_RETURN_NOT_FOUND;
} else if (acceptedOnly) {
errorCode = StateApiErrorCode.E_TAX_RETURN_NOT_ACCEPTED;
} else {
errorCode = StateApiErrorCode.E_TAX_RETURN_NOT_ACCEPTED_OR_PENDING;
}
log.error(
"generateAuthorizationToken() failed for taxReturnId={}, submissionId={}, taxYear={}. Error: {}",
acRequest.getTaxReturnUuid(),
acRequest.getSubmissionId(),
acRequest.getTaxYear(),
errorCode.name());
throw new StateApiException(errorCode);
}
})
.flatMap(jwtMono -> jwtMono.map(token -> token));
}
@Override
public Mono<StateAndAuthCode> verifyJwtSignature(String jwtToken, String accountId) {
log.info("enter verifyJwtSignature()...");
return getStateProfile(accountId)
.zipWhen(sp -> {
return retrievePublicKeyFromCert(sp);
})
.map(tuple -> {
try {
ClientJwtClaim claim = JwtVerifier.verifyJwt(jwtToken, tuple.getT2());
return new StateAndAuthCode(
claim.getAuthorizationCode(), tuple.getT1().getStateCode());
} catch (FileNotFoundException e) {
log.error(
"verifyJwtSignature() failed, cannot locate the public key file, {}, error: {}",
e.getClass(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_CERTIFICATE_NOT_FOUND);
} catch (CertificateExpiredException e) {
log.error(
"verifyJwtSignature() failed, the certificate ({}) for {} has expired, {}, error: {}",
tuple.getT1().getCertLocation(),
tuple.getT1().getAccountId(),
e.getClass(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_CERTIFICATE_EXPIRED);
} catch (Exception e) {
log.error(
"verifyJwtSignature() failed, {}, error: {}",
e.getClass().getName(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_JWT_VERIFICATION_FAILED);
}
});
}
@Override
public Mono<AuthorizationCode> authorize(StateAndAuthCode saCode) {
log.info("enter authorize()...");
UUID authorizationCode;
try {
authorizationCode = UUID.fromString(saCode.getAuthorizationCode());
} catch (IllegalArgumentException exception) {
log.error("authorize() failed, authorization code is not in a valid UUID format");
throw new StateApiException(StateApiErrorCode.E_AUTHORIZATION_CODE_INVALID_FORMAT);
}
// Returns authorization-code entity if authorizationCode exists and is valid
// otherwise exception
return getAuthorizationCode(authorizationCode).flatMap(ac -> {
// The state code used to generate the authorization code must match the state
// code associated with
// the requester's accountId
String authorizationCodeStateCode = ac.getStateCode();
String stateProfileStateCode = saCode.getStateCode();
if (!stateProfileStateCode.equals(authorizationCodeStateCode)) {
log.error(
"authorize() failed, mismatched state code, state_profile state code : {}, authorization_code state code: {}",
stateProfileStateCode,
authorizationCodeStateCode);
throw new StateApiException(StateApiErrorCode.E_MISMATCHED_STATE_CODE);
}
// The authorization code must not be expired
Timestamp expiresAt = ac.getExpiresAt();
if (expiresAt.getTime() < System.currentTimeMillis()) {
log.error("authorize() failed, authorization code has expired");
throw new StateApiException(StateApiErrorCode.E_AUTHORIZATION_CODE_EXPIRED);
}
return Mono.just(ac);
});
}
@Override
public Mono<TaxReturnXml> retrieveTaxReturnXml(int taxYear, UUID taxReturnUuid, String submissionId) {
log.info("enter retrieveTaxReturnXml()...taxReturnUuid={}", taxReturnUuid);
return dfClient.getStatus(taxYear, taxReturnUuid, submissionId).flatMap(taxReturnStatus -> {
if (STATUS_REJECTED.equalsIgnoreCase(taxReturnStatus.status())) {
log.error("Requested tax return has status of 'rejected'");
return Mono.error(new StateApiException(StateApiErrorCode.E_TAX_RETURN_NOT_ACCEPTED_OR_PENDING));
}
if (STATUS_ERROR.equalsIgnoreCase(taxReturnStatus.status())) {
log.error("Requested tax return has status of 'error'");
throw new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
}
return stateApiS3Client
.getTaxReturnXml(taxYear, taxReturnUuid, submissionId)
.map(xmlString -> new TaxReturnXml(taxReturnStatus.status().toLowerCase(), submissionId, xmlString))
.onErrorMap(e -> {
log.error(
"getTaxReturnXml() failed for taxYear={}, taxReturnId={}, submissionId={}, {}, error: {}",
taxYear,
taxReturnUuid.toString(),
submissionId,
e.getClass().getName(),
e.getMessage());
return new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
});
});
}
@Override
public Mono<Map<String, Object>> retrieveExportedFacts(String submissionId, String stateCode, String accountId) {
log.info(
"enter retrieveExportedFacts()...submissionId={}, acctId={}, enabled={}",
submissionId,
accountId,
exported_facts_enabled);
if (exported_facts_enabled) {
return efClient.getExportedFacts(submissionId, stateCode, accountId)
.map(GetStateExportedFactsResponse::exportedFacts);
}
return Mono.just(new HashMap<>());
}
@Override
public Mono<EncryptData> encryptTaxReturn(TaxReturnToExport taxReturn, String accountId) {
log.info("enter encryptTaxReturn()...accountId={}", accountId);
// Compared AES 256 CBC, CCM and GCM, we chose GCM for combining data
// confidentiality and integrity/authentication in a highly efficient manner.
return getStateProfile(accountId)
.flatMap(sp -> {
return retrievePublicKeyFromCert(sp);
})
.map(publicKey -> {
try {
// 1. generate secret
byte[] secret = generatePassword();
// 2. generate a random initialization vector for AES CBC
byte[] iv = generateIV();
// 3. encrypt the xml data with AES 256 GCM
var gsonBuilder = new GsonBuilder();
gsonBuilder.serializeNulls();
Gson gson = gsonBuilder.create();
AesGcmEncryptionResult encryptedResult = aesGcmEncrypt(gson.toJson(taxReturn), secret, iv);
// 4. Encode the encrypted xml
String encodedAndEncryptedData = Base64.toBase64String(encryptedResult.ciphertext());
// 5. encrypt the secret with state's public key and encode with base64
byte[] encryptedSecret = rsaEncryptWithPublicKey(secret, publicKey);
String encodedSecret = Base64.toBase64String(encryptedSecret);
// 6. encode iv
String encodedIV = Base64.toBase64String(iv);
// 7. encode authentication tag
String encodedAuthenticationTag = Base64.toBase64String(encryptedResult.authenticationTag());
return new EncryptData(
encodedSecret, encodedIV, encodedAndEncryptedData, encodedAuthenticationTag);
} catch (Exception e) {
log.error(
"encryptTaxReturnXml() failed, {}, error: {}",
e.getClass().getName(),
e.getMessage());
throw new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
}
});
}
private Mono<Boolean> getAcceptedOnlyFlag(String stateCode) {
log.info("enter getAcceptedOnlyFlag...");
return lookupStateProfile(stateCode)
.doOnSuccess(s -> log.info("getAcceptedOnlyFlag completes successfully, flag: {}", s.acceptedOnly()))
.doOnError(er -> log.error("getAcceptedOnlyFlag fails: {}", er.getMessage()))
.flatMap(sp -> Mono.just(sp.acceptedOnly()));
}
@Override
public Mono<StateProfileDTO> lookupStateProfile(String stateCode) {
return cachedDS.getStateProfileByStateCode(stateCode).map(dto -> {
if (dto.archived()) {
log.error("State {} is archived", stateCode);
throw new StateApiException(StateApiErrorCode.E_ACCOUNT_ARCHIVED);
}
return dto;
});
}
@Override
public Mono<StateProfile> getStateProfile(String accountId) {
return cachedDS.getStateProfile(accountId).map(sp -> {
if (sp.getArchived()) {
log.error("State {} (account_id={}) is archived", sp.getStateCode(), accountId);
throw new StateApiException(StateApiErrorCode.E_ACCOUNT_ARCHIVED);
}
return sp;
});
}
private Mono<AuthorizationCode> getAuthorizationCode(UUID authorizationCode) {
log.info("enter getAuthorizationCode()...");
AuthorizationCode code = new AuthorizationCode();
code.setAuthorizationCode(authorizationCode);
return acRepo.getByAuthorizationCode(code.getAuthorizationCode())
.switchIfEmpty(Mono.defer(() -> {
log.error(
"getAuthorizationCode() failed, authorization code {} does not exist in authorization_code table",
authorizationCode);
return Mono.error(new StateApiException(StateApiErrorCode.E_AUTHORIZATION_CODE_NOT_EXIST));
}))
.onErrorMap(e -> !(e instanceof StateApiException), e -> {
log.error(
"getAuthorizationCode() failed, {}, error: {}",
e.getClass().getName(),
e.getMessage());
return new StateApiException(StateApiErrorCode.E_INTERNAL_SERVER_ERROR);
});
}
// Check if using default cert in lower env
private Mono<PublicKey> retrievePublicKeyFromCert(StateProfile sp) {
String certOverride = certProperties.getCertLocationOverride();
String cert = (StringUtils.isBlank(certOverride)) ? sp.getCertLocation() : certOverride;
OffsetDateTime expDate = (StringUtils.isBlank(certOverride))
? sp.getCertExpirationDate()
: OffsetDateTime.now().plusYears(1);
log.info("Use CertOverride={} to retrieve public key.", certOverride);
return cachedDS.retrievePublicKeyFromCert(cert, expDate);
}
}

View file

@ -0,0 +1,20 @@
package gov.irs.directfile.stateapi.utils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;
@UtilityClass
public class IPAddress {
private static String X_FORWARDED_FOR = "X-Forwarded-For";
public String getClientIpAddress(HttpServletRequest request) {
String addr = request.getHeader(X_FORWARDED_FOR);
if (addr == null || addr.isEmpty()) {
addr = request.getRemoteAddr();
} else {
String[] addrs = addr.split(",");
addr = addrs[0];
}
return addr;
}
}

View file

@ -0,0 +1,20 @@
# set debug: true to log CONDITIONS EVALUATION REPORT
debug: false
logging:
config: classpath:logback-debug.xml
file:
name: spring-boot-debug.log
clean-history-on-start: true
management:
endpoints:
enabled-by-default: true
web:
discovery:
enabled: true
exposure:
include: "*"
endpoint:
env:
show-values: ALWAYS
configprops:
show-values: ALWAYS

View file

@ -0,0 +1,20 @@
spring:
liquibase:
contexts: dev
authorization-token:
signing-key: GTc+SlI7C7ECPHAhAvIWqn2yAvzAGMVj
aws:
enabled: false
s3:
accessKey: accessKey
secretKey: secretKey
region: us-west-2
certBucketName: cert-bucket
endPoint: http://s3.localhost.localstack.cloud:4566/
direct-file:
local-encryption:
local-wrapping-key: ${LOCAL_WRAPPING_KEY}
cert-location-override: fakestate.cer

View file

@ -0,0 +1,19 @@
server:
port: 8080
spring:
r2dbc:
url: r2dbc:postgresql://state-api-db:5432/stateapi
liquibase:
url: jdbc:postgresql://state-api-db:5432/stateapi
contexts: docker
aws:
s3:
accessKey: accessKey
secretKey: secretKey
region: us-west-2
endPoint: http://s3.localhost.localstack.cloud:4566
direct-file:
backend-url: http://api:${DF_API_PORT:8080}/

View file

@ -0,0 +1,91 @@
server:
port: ${STATEAPI_PORT:8081}
shutdown: graceful
logging:
config: classpath:logback.xml
spring:
datasource:
username: postgres
password: postgres
r2dbc:
url: r2dbc:postgresql://localhost:${STATEAPI_DB_PORT:5433}/stateapi
username: ${spring.datasource.username}
password: ${spring.datasource.password}
liquibase:
change-log: classpath:db/changelog.yaml
url: jdbc:postgresql://localhost:${STATEAPI_DB_PORT:5433}/stateapi
user: ${spring.datasource.username}
password: ${spring.datasource.password}
# "If you do not specify any contexts in the CLI at runtime, every changeset in your changelog runs, even if they
# have contextFilters attached"
# Setting contexts in the base application.yaml ensures that contextFilters overridden in other profiles are respected
contexts: base
cache:
TTL-minutes: 120
type: caffeine # Enable caching with Caffeine, use "none" to disable caching
aws:
default-credentials-provider-chain-enabled: false
s3:
default-credentials-provider-chain-enabled: false
accessKey: accessKey
secretKey: secretKey
region: us-east-1
endPoint: http://localhost:4566
certBucketName: cert-bucket
taxReturnXmlBucketName: direct-file-taxreturns
prefix:
authorization-code:
expires-interval-seconds: 600
# now we are pointing to direct-file-status app to get status and also xml file, and backend to get exported facts
direct-file:
backend-url: http://localhost:8080/
backend-context-path: /df/file/api
backend-api-version: v1
status:
mock: false
url: ${direct-file.backend-url}
exported-facts:
enabled: true
mock: false
url: ${direct-file.backend-url}
management:
endpoint:
health:
enabled: true
endpoints:
enabled-by-default: false
web:
discovery:
enabled: false
exposure:
include: health
springdoc:
api-docs:
path: /api-docs
swagger-ui:
enabled: true
supported-submit-methods: []
feature-flags:
export-return:
variants:
on: true
off: false
default-variant: on
openfeature-starter:
s3-provider:
environment-prefix:
bucket: direct-file-taxreturns
object: "feature-flags.json"
expiration: 15m
xml-sanitized:
allowed-headers:
excluded-tags:

View file

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIC/TCCAeWgAwIBAgIUb0j9n0LLrRKshU09iqiA3XOJ7V4wDQYJKoZIhvcNAQEL
BQAwJzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkZTMQswCQYDVQQHDAJEQzAeFw0y
NDA4MzAxNTIwNTNaFw0yNTA4MzAxNTIwNTNaMCcxCzAJBgNVBAYTAlVTMQswCQYD
VQQIDAJGUzELMAkGA1UEBwwCREMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQDd8FAPO2dC0iLtMXb7TGEzUHoG9f5fhExyXfoZe25Xx/RH3aBzRTdb2RDG
AVENfV6u6rubZD3NVs6EVw5AYZ3+KDcZgSoogAU4t3LxASmX246QSeqjZs34WBYj
PG+yZ5ndxGF1QV1pGjuEoK+dk/KgKhduiJLYcw3fyYcreClAGsfONTW+AeBpms6V
Zu6awh8XGLwxJ/CLJa3OEIXGbM/l3pXt0Uu0GVOJ2a3qf5lJXD2t/umNtI6fOASB
d1lEUMsBK7wrehgK8vgkBFDGZKLpvN3U/c9vK9obm8rUAOcd6KYOqIiK/ly1/1aJ
leSGo/3Jv9NdaqRNPpUfODZxDAZ1AgMBAAGjITAfMB0GA1UdDgQWBBTdXF467ChC
IEY8hh+sn0kQXH+ndzANBgkqhkiG9w0BAQsFAAOCAQEAVPD0lS3k8A4IhqHAPTTG
ScTNW6E2bMrzkKc6YBvxGfcqXyJgrH0eyiqf5RG3G+2iM9RSiDBIP3IPshEcRYO8
+auN8TjLnm24mLIQlgtwEx2n8/nvUDxuE979XGIu4G5+xdHhajpsKR7B9yWIZQcm
nxkTZaI9tTgFu6/6kjdQocPBH1V0pFaQTIeiFA6TTnhhM9weQf/zLpg5azMV8Oqc
QPuI1NKoWF611iMxo2MHudYEWDW6OPXzEfNJr/LI03PvIFoeQGWTW10PdnC5W+yn
RgYgKQWHegXkRhvHYcnIXtatEeicFqtVbpwT8+zJv2wEE23FXOe5anOuF3Lm7Jhs
TQ==
-----END CERTIFICATE-----

View file

@ -0,0 +1,7 @@
databaseChangeLog:
- preConditions:
onFail: HALT
onError: HALT
- includeAll:
path: migrations/
relativeToChangelogFile: true

View file

@ -0,0 +1,136 @@
databaseChangeLog:
- changeSet:
id: initial-schema-1
author: abcde
changes:
- createTable:
tableName: authorization_code
columns:
- column:
name: id
type: serial
constraints:
nullable: false
primaryKey: true
primaryKeyName: authorization_code_pkey
- column:
name: tax_return_uuid
type: uuid
constraints:
nullable: false
- column:
name: tax_year
type: int
constraints:
nullable: false
- column:
name: authorization_code
type: char(64)
constraints:
unique: true
nullable: false
- column:
name: created_at
type: timestamp
defaultValueComputed: 'CURRENT_TIMESTAMP'
- column:
name: expires_at
type: timestamp
defaultValueComputed: 'CURRENT_TIMESTAMP + interval ''120 minutes'''
- createTable:
tableName: state_profile
columns:
- column:
name: id
type: serial
constraints:
nullable: false
primaryKey: true
primaryKeyName: state_profile_pkey
- column:
name: account_id
type: varchar(10)
constraints:
unique: true
nullable: false
- column:
name: state_code
type: char(2)
constraints:
nullable: false
unique: true
- column:
name: tax_system_name
type: varchar
constraints:
nullable: false
- column:
name: landing_url
type: varchar
constraints:
nullable: false
- column:
name: default_redirect_url
type: varchar
constraints:
nullable: true
- column:
name: accepted_only
type: boolean
constraints:
nullable: false
- column:
name: cert_location
type: text
constraints:
nullable: true
- column:
name: created_at
type: timestamp
defaultValueComputed: 'CURRENT_TIMESTAMP'
constraints:
nullable: false
- column:
name: cert_expiration_date
type: timestamp with time zone
defaultValueComputed: (CURRENT_TIMESTAMP + INTERVAL '1 year')
constraints:
nullable: true
- createTable:
tableName: state_redirect
columns:
- column:
name: id
type: serial
constraints:
nullable: false
primaryKey: true
primaryKeyName: state_redirect_pkey
- column:
name: state_profile_id
type: serial
constraints:
nullable: false
- column:
name: redirect_url
type: varchar(255)
constraints:
nullable: false
- column:
name: created_at
type: timestamp
defaultValueComputed: 'CURRENT_TIMESTAMP'
constraints:
nullable: false
- column:
name: expires_at
type: timestamp
- addForeignKeyConstraint:
baseColumnNames: state_profile_id
baseTableName: state_redirect
constraintName: state_profile_id_fkey
deferrable: false
initiallyDeferred: false
referencedColumnNames: id
referencedTableName: state_profile
validate: true

View file

@ -0,0 +1,22 @@
databaseChangeLog:
- changeSet:
# Note this change deletes the testing data from the
# `load-test-authorization-codes` changeSet
id: delete-all-preexisting-authorization-codes
author: abcde
changes:
- delete:
tableName: authorization_code
where: 1=1
- changeSet:
id: authorization-code-addColumn-state-code
author: bl
changes:
- addColumn:
tableName: authorization_code
columns:
- column:
name: state_code
type: char(2)
constraints:
nullable: false

View file

@ -0,0 +1,14 @@
databaseChangeLog:
- changeSet:
id: state-profile-addColumn-archived
author: abcde
changes:
- addColumn:
tableName: state_profile
columns:
- column:
name: archived
type: boolean
defaultValueBoolean: false
constraints:
nullable: false

View file

@ -0,0 +1,28 @@
databaseChangeLog:
- changeSet:
id: state-profile-add-cancel-url-columns
author: abcde
changes:
- addColumn:
tableName: state_profile
columns:
- column:
name: transfer_cancel_url
type: varchar
constraints:
nullable: true
- addColumn:
tableName: state_profile
columns:
- column:
name: waiting_for_acceptance_cancel_url
type: varchar
constraints:
nullable: true
rollback:
- dropColumn:
tableName: state_profile
columnName: transfer_cancel_url
- dropColumn:
tableName: state_profile
columnName: waiting_for_acceptance_cancel_url

View file

@ -0,0 +1,13 @@
databaseChangeLog:
- changeSet:
id: auth-codes-add-submission-id-column
author: abcde
changes:
- addColumn:
tableName: authorization_code
columns:
- column:
name: submission_id
type: varchar(20)
constraints:
nullable: true

View file

@ -0,0 +1,44 @@
databaseChangeLog:
- changeSet:
id: 202403081531-00-add-state-language-table
author: abcde
changes:
- createTable:
tableName: state_language
columns:
- column:
name: id
type: serial
constraints:
nullable: false
primaryKey: true
primaryKeyName: state_language_pkey
- column:
name: state_profile_id
type: serial
constraints:
nullable: false
- column:
name: df_language_code
type: char(2)
constraints:
nullable: false
- column:
name: state_language_code
# Assuming this is enough characters if a state uses a full string for the language name in their URL
# or something. Can always increase with a migration later
type: varchar(50)
constraints:
nullable: false
- addForeignKeyConstraint:
baseColumnNames: state_profile_id
baseTableName: state_language
constraintName: state_profile_id_fkey
deferrable: false
initiallyDeferred: false
referencedColumnNames: id
referencedTableName: state_profile
validate: true
rollback:
- dropTable:
tableName: state_language

View file

@ -0,0 +1,17 @@
databaseChangeLog:
- changeSet:
id: state-profile-add-filing-requirements-url-column
author: abcde
changes:
- addColumn:
tableName: state_profile
columns:
- column:
name: filing_requirements_url
type: varchar
constraints:
nullable: true
rollback:
- dropColumn:
tableName: state_profile
columnName: filing_requirements_url

View file

@ -0,0 +1,17 @@
databaseChangeLog:
- changeSet:
id: state-profile-add-department-of-revenue-url-column
author: abcde
changes:
- addColumn:
tableName: state_profile
columns:
- column:
name: department_of_revenue_url
type: varchar
constraints:
nullable: true
rollback:
- dropColumn:
tableName: state_profile
columnName: department_of_revenue_url

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<include resource="org/springframework/boot/logging/logback/file-appender.xml" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<logger name="org.springframework.web" level="DEBUG" />
</configuration>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration>
<configuration>
<appender name="ConsoleJSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<fieldNames>
<timestamp>timestamp</timestamp>
<version>[ignore]</version>
<levelValue>[ignore]</levelValue>
</fieldNames>
<timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
<timeZone>UTC</timeZone>
<customFields>{"system":"DIRECTFILE","version":"${GIT_COMMIT_HASH}"}</customFields>
<!-- Only include the following fields from the Mapped Diagnostic Context (MDC). -->
<!-- Other MDC fields will be excluded. -->
<includeMdcKeyName>eventId</includeMdcKeyName>
<includeMdcKeyName>eventStatus</includeMdcKeyName>
<includeMdcKeyName>eventType</includeMdcKeyName>
<includeMdcKeyName>eventErrorMessage</includeMdcKeyName>
<includeMdcKeyName>responseStatusCode</includeMdcKeyName>
<includeMdcKeyName>system</includeMdcKeyName>
<includeMdcKeyName>taxPeriod</includeMdcKeyName>
<includeMdcKeyName>taxReturnType</includeMdcKeyName>
<includeMdcKeyName>stateId</includeMdcKeyName>
<includeMdcKeyName>userType</includeMdcKeyName>
<includeMdcKeyName>cyberOnly</includeMdcKeyName>
<includeMdcKeyName>taxReturnId</includeMdcKeyName>
<includeMdcKeyName>detail</includeMdcKeyName>
<throwableConverter class="gov.irs.directfile.stateapi.audit.NoMessageStackTraceConverter" />
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="ConsoleJSON" />
</root>
</configuration>

View file

@ -0,0 +1,115 @@
package gov.irs.directfile.stateapi;
import java.security.Security;
import java.util.*;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.web.client.RestTemplate;
import software.amazon.encryption.s3.S3AsyncEncryptionClient;
import gov.irs.directfile.stateapi.configuration.S3ConfigurationProperties;
import gov.irs.directfile.stateapi.encryption.JwtSigner;
import gov.irs.directfile.stateapi.model.ExportResponse;
import gov.irs.directfile.stateapi.repository.AuthorizationCodeRepository;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
properties = {"server.port=8082"})
@ActiveProfiles({"development", "test", "integration-test"})
@Slf4j
@SuppressWarnings("null")
@EnabledIfSystemProperty(named = "runIntegrationTests", matches = "true")
public class StateApiAppNoOverrideTest {
private static final String PRIVATE_KEY_PATH = "src/test/resources/certificates/fakestate.key";
private static final String URL_EXPORT = "http://localhost:8082/state-api/export-return";
private final RestTemplate restTemplate = new RestTemplate();
private final HttpHeaders headers = new HttpHeaders();
private HttpEntity<Map<String, Object>> requestEntity;
@Autowired
AuthorizationCodeRepository authorizationCodeRepository;
@Autowired
S3AsyncEncryptionClient s3AsyncEncryptionClient;
@Autowired
S3ConfigurationProperties s3ConfigurationProperties;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
log.info("Set cert-location-override=false");
registry.add("direct-file.cert-location-override", () -> "");
}
@BeforeEach
public void setUp() {
Security.addProvider(new BouncyCastleProvider());
headers.setContentType(MediaType.APPLICATION_JSON);
}
@Test
public void integrationTests_CertificateExpired() throws Exception {
String ac = "d6e34be4-11df-4b5e-808f-bc48a9f4870b";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"112233\",\"sub\":\"" + ac + "\",\"iat\":1516239022}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "112233");
headers.set("Authorization", "Bearer " + jwtToken);
requestEntity = new HttpEntity<>(headers);
var response = restTemplate.exchange(URL_EXPORT, HttpMethod.GET, requestEntity, ExportResponse.class);
ExportResponse responseBody = response.getBody();
assertNotNull(responseBody);
assertEquals("error", responseBody.getStatus());
assertEquals("E_CERTIFICATE_EXPIRED", responseBody.getError());
log.info("Test succeeded with expected error: E_CERTIFICATE_EXPIRED");
}
@Test
public void integrationTests_InternalServerError() throws Exception {
String ac = "d6e34be4-11df-4b5e-808f-bc48a9f4870b";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"112234\",\"sub\":\"" + ac + "\",\"iat\":1516239022}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "112234");
headers.set("Authorization", "Bearer " + jwtToken);
requestEntity = new HttpEntity<>(headers);
var response = restTemplate.exchange(URL_EXPORT, HttpMethod.GET, requestEntity, ExportResponse.class);
ExportResponse responseBody = response.getBody();
assertNotNull(responseBody);
assertEquals("error", responseBody.getStatus());
assertEquals("E_INTERNAL_SERVER_ERROR", responseBody.getError());
log.info("Test succeeded with expected error: E_INTERNAL_SERVER_ERROR");
}
}

View file

@ -0,0 +1,450 @@
package gov.irs.directfile.stateapi;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Security;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.Period;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.model.DeleteObjectResponse;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.encryption.s3.S3AsyncEncryptionClient;
import gov.irs.directfile.dto.AuthCodeResponse;
import gov.irs.directfile.stateapi.configuration.S3ConfigurationProperties;
import gov.irs.directfile.stateapi.encryption.JwtSigner;
import gov.irs.directfile.stateapi.model.AuthCodeRequest;
import gov.irs.directfile.stateapi.model.AuthorizationCode;
import gov.irs.directfile.stateapi.model.ExportResponse;
import gov.irs.directfile.stateapi.model.TaxReturnToExport;
import gov.irs.directfile.stateapi.repository.AuthorizationCodeRepository;
import static gov.irs.directfile.stateapi.encryption.Decryptor.aesGcmDecrypt;
import static gov.irs.directfile.stateapi.encryption.Decryptor.rsaDecryptWithPrivateKey;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
properties = {"server.port=8081"})
@ActiveProfiles({"development", "test", "integration-test"})
@Slf4j
@SuppressWarnings("null")
@EnabledIfSystemProperty(named = "runIntegrationTests", matches = "true")
public class StateApiAppTest {
private static final String PRIVATE_KEY_PATH = "src/test/resources/certificates/fakestate.key";
private static final String SUBMISSION_ID = "someSubmissionId";
private static final String TAX_YEAR = "2022";
private static final String TAX_RETURN_ID = "ae019609-99e0-4ef5-85bb-ad90dc302e70";
private static final String XML_OBJECT_KEY =
TAX_YEAR + "/taxreturns/" + TAX_RETURN_ID + "/submissions/" + SUBMISSION_ID + ".xml";
private static final String AUTHORIZATION_CODE_URL = "http://localhost:8081/state-api/authorization-code";
private static final String EXPORT_RETURN_URL = "http://localhost:8081/state-api/export-return";
private final RestTemplate restTemplate = new RestTemplate();
private final HttpHeaders headers = new HttpHeaders();
@Autowired
AuthorizationCodeRepository authorizationCodeRepository;
@Autowired
S3AsyncEncryptionClient s3AsyncEncryptionClient;
@Autowired
S3ConfigurationProperties s3ConfigurationProperties;
@BeforeEach
public void setUp() {
Security.addProvider(new BouncyCastleProvider());
headers.setContentType(MediaType.APPLICATION_JSON);
}
private void putEncryptedXml() {
Path resourceDir = Paths.get("src/test/resources/xml/sample-export.xml");
// Call putObject to encrypt the object and upload it to S3
CompletableFuture<PutObjectResponse> futurePut = s3AsyncEncryptionClient.putObject(
builder -> builder.bucket(s3ConfigurationProperties.getTaxReturnXmlBucketName())
.key(XML_OBJECT_KEY)
.build(),
resourceDir);
// Block on completion of the futurePut
futurePut.join();
}
private void deleteEncryptedXml() {
CompletableFuture<DeleteObjectResponse> futureDelete = s3AsyncEncryptionClient.deleteObject(
builder -> builder.bucket(s3ConfigurationProperties.getTaxReturnXmlBucketName())
.key(XML_OBJECT_KEY)
.build());
futureDelete.join();
}
private HttpEntity<AuthCodeRequest> createAuthCodeRequestBodyAndEntity(String uuid, int taxYear, String stateCode) {
var tin = "213456789";
AuthCodeRequest requestObject =
new AuthCodeRequest(UUID.fromString(uuid), tin, taxYear, stateCode, SUBMISSION_ID);
return new HttpEntity<>(requestObject, headers);
}
@Test
public void integrationTests_successfulResponse() throws Exception {
putEncryptedXml();
var authCodeRequestEntity = createAuthCodeRequestBodyAndEntity(TAX_RETURN_ID, Integer.parseInt(TAX_YEAR), "FS");
ResponseEntity<AuthCodeResponse> authCodeResponse =
restTemplate.postForEntity(AUTHORIZATION_CODE_URL, authCodeRequestEntity, AuthCodeResponse.class);
UUID ac = authCodeResponse.getBody().getAuthCode();
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"123456\",\"sub\":\"" + ac.toString() + "\",\"iat\":1632736766}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("Authorization", "Bearer " + jwtToken);
var exportReturnRequestEntity = new HttpEntity<>(headers);
var exportResponse =
restTemplate.exchange(EXPORT_RETURN_URL, HttpMethod.GET, exportReturnRequestEntity, String.class);
if (Objects.requireNonNull(exportResponse.getBody()).contains("error"))
throw new AssertionError("Integration tests failed unexpectedly!");
String encodedSecret = exportResponse.getHeaders().get("SESSION-KEY").getFirst();
String encodedIV =
exportResponse.getHeaders().get("INITIALIZATION-VECTOR").getFirst();
String encodedAuthenticationTag =
exportResponse.getHeaders().get("AUTHENTICATION-TAG").getFirst();
String taxReturn = exportResponse.getBody();
ObjectMapper om = new ObjectMapper();
ExportResponse response = om.readValue(taxReturn.getBytes(), ExportResponse.class);
if (!response.getStatus().equals("success")) throw new AssertionError("Integration tests failed unexpectedly!");
byte[] secret = rsaDecryptWithPrivateKey(Base64.getDecoder().decode(encodedSecret), PRIVATE_KEY_PATH);
// decrypt the data using the secret and iv
byte[] decryptedBytes = aesGcmDecrypt(
Base64.getDecoder().decode(response.getTaxReturn()),
secret,
Base64.getDecoder().decode(encodedIV),
Base64.getDecoder().decode(encodedAuthenticationTag));
TaxReturnToExport taxReturnData = om.readValue(decryptedBytes, TaxReturnToExport.class);
assertEquals(taxReturnData.getStatus(), "accepted");
assertEquals(taxReturnData.getSubmissionId(), SUBMISSION_ID);
assertNotNull(taxReturnData.getXml());
assertFalse(taxReturnData.getDirectFileData().isEmpty());
assertTrue(taxReturnData.getDirectFileData().containsKey("familyAndHousehold"));
var familyAndHousehold =
(List<Map<String, Object>>) taxReturnData.getDirectFileData().get("familyAndHousehold");
var expectedPerson = new HashMap<>();
expectedPerson.put("firstName", "Sammy");
expectedPerson.put("middleInitial", null);
expectedPerson.put("lastName", "Smith");
expectedPerson.put("suffix", "I");
expectedPerson.put("dateOfBirth", "2013-01-21");
expectedPerson.put("tin", "200-01-1234");
expectedPerson.put("relationship", "biologicalChild");
expectedPerson.put("isClaimedDependent", true);
expectedPerson.put("eligibleDependent", true);
expectedPerson.put("residencyDuration", "allYear");
expectedPerson.put("monthsLivedWithTPInUS", "twelve");
expectedPerson.put("ssnNotValidForEmployment", false);
expectedPerson.put("qualifyingChild", true);
expectedPerson.put("hohQualifyingPerson", true);
expectedPerson.put("scheduleEicLine4bYes", false);
expectedPerson.put("scheduleEicLine4aYes", true);
expectedPerson.put("scheduleEicLine4aNo", false);
assertEquals(1, familyAndHousehold.size());
assertEquals(expectedPerson, familyAndHousehold.getFirst());
var filers =
(List<Map<String, Object>>) taxReturnData.getDirectFileData().get("filers");
var expectedFiler1 = new HashMap<>();
expectedFiler1.put("firstName", "Samuel");
expectedFiler1.put("middleInitial", null);
expectedFiler1.put("lastName", "Smith");
expectedFiler1.put("suffix", "Jr");
expectedFiler1.put("dateOfBirth", "1985-09-29");
expectedFiler1.put("tin", "100-01-1234");
expectedFiler1.put("isPrimaryFiler", true);
expectedFiler1.put("ssnNotValidForEmployment", false);
expectedFiler1.put("educatorExpenses", "200.00");
expectedFiler1.put("hsaTotalDeductibleAmount", "600.00");
expectedFiler1.put("isDisabled", false);
expectedFiler1.put("isStudent", false);
expectedFiler1.put("interestReportsTotal", "3000.00");
expectedFiler1.put("form1099GsTotal", "15000.00");
var expectedFiler2 = new HashMap<>();
expectedFiler2.put("firstName", "Judy");
expectedFiler2.put("middleInitial", null);
expectedFiler2.put("lastName", "Johnson");
expectedFiler2.put("suffix", null);
expectedFiler2.put("dateOfBirth", "1985-10-18");
expectedFiler2.put("tin", "100-02-1234");
expectedFiler2.put("isPrimaryFiler", false);
expectedFiler2.put("ssnNotValidForEmployment", false);
expectedFiler2.put("educatorExpenses", "100.00");
expectedFiler2.put("hsaTotalDeductibleAmount", "500.00");
expectedFiler2.put("isDisabled", false);
expectedFiler2.put("isStudent", false);
expectedFiler2.put("interestReportsTotal", "300.00");
expectedFiler2.put("form1099GsTotal", "1500.00");
assertEquals(2, filers.size());
assertThat(filers, containsInAnyOrder(expectedFiler1, expectedFiler2));
var intReports =
(List<Map<String, Object>>) taxReturnData.getDirectFileData().get("interestReports");
var intRpt = new HashMap<String, Object>();
intRpt.put("has1099", true);
intRpt.put("1099Amount", "800.00");
intRpt.put("interestOnGovernmentBonds", "300");
intRpt.put("taxExemptInterest", "200.00");
intRpt.put("recipientTin", "123-45-6789");
intRpt.put("no1099Amount", null);
intRpt.put("payer", "JPM Bank");
intRpt.put("payerTin", "01-1234567");
intRpt.put("taxWithheld", "120");
intRpt.put("taxExemptAndTaxCreditBondCusipNo", "01234567A");
assertEquals(1, intReports.size());
assertEquals(intRpt, intReports.getFirst());
var form1099Gs =
(List<Map<String, Object>>) taxReturnData.getDirectFileData().get("form1099Gs");
var form1099G = new HashMap<String, Object>();
form1099G.put("has1099", true);
form1099G.put("recipientTin", "123-45-6789");
form1099G.put("payer", "State of california");
form1099G.put("payerTin", "321-54-9876");
form1099G.put("amount", "100.00");
form1099G.put("federalTaxWithheld", "20.00");
form1099G.put("stateIdNumber", "123456");
form1099G.put("stateTaxWithheld", "10.00");
form1099G.put("amountPaidBackForBenefitsInTaxYear", "25.00");
assertEquals(1, form1099Gs.size());
assertEquals(form1099G, form1099Gs.getFirst());
// socialSecurityReports
var ssRpts =
(List<Map<String, Object>>) taxReturnData.getDirectFileData().get("socialSecurityReports");
var ssRpt = new HashMap<String, Object>();
ssRpt.put("recipientTin", "123-45-6789");
ssRpt.put("netBenefits", "21000.00");
ssRpt.put("formType", "SSA-1099");
assertEquals(1, ssRpts.size());
assertEquals(ssRpt, ssRpts.getFirst());
var formW2s =
(List<Map<String, Object>>) taxReturnData.getDirectFileData().get("formW2s");
assertEquals(1, formW2s.size());
var expectedW2 = new HashMap<String, Object>();
expectedW2.put("unionDuesAmount", "40.00");
expectedW2.put("BOX14_NJ_UIHCWD", "101.00");
expectedW2.put("BOX14_NJ_UIWFSWF", "101.00");
assertEquals(expectedW2, formW2s.getFirst());
deleteEncryptedXml();
log.info("Integration tests succeeded");
}
@Test
public void integrationTests_AccountIdNotExist() throws Exception {
String ac = "d6e34be4-11df-4b5e-808f-bc48a9f4870b";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"12345\",\"sub\":\"" + ac + "\",\"iat\":1632736766}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "12345");
headers.set("Authorization", "Bearer " + jwtToken);
var exportReturnRequestEntity = new HttpEntity<>(headers);
var response = restTemplate.exchange(
EXPORT_RETURN_URL, HttpMethod.GET, exportReturnRequestEntity, ExportResponse.class);
ExportResponse responseBody = response.getBody();
assertNotNull(responseBody);
assertEquals("error", responseBody.getStatus());
assertEquals("E_ACCOUNT_ID_NOT_EXIST", responseBody.getError());
log.info("Test succeeded with expected error: E_ACCOUNT_ID_NOT_EXIST");
}
@Test
public void integrationTests_BearerTokenMissing() throws Exception {
String ac = "d6e34be4-11df-4b5e-808f-bc48a9f4870b";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"123456\",\"sub\":\"" + ac + "\",\"iat\":1632736766}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "123456");
headers.set("Authorization", "NoBearer " + jwtToken);
var exportRequest = new HttpEntity<>(headers);
var exportResponse = restTemplate.exchange(EXPORT_RETURN_URL, HttpMethod.GET, exportRequest, String.class);
String responseBody = exportResponse.getBody();
assertNotNull(responseBody);
assertTrue(responseBody.startsWith("{\"status\":\"error\""));
assertTrue(responseBody.contains("E_BEARER_TOKEN_MISSING"));
log.info("Test succeeded with expected error: E_BEARER_TOKEN_MISSING");
}
@Test
public void integrationTests_AuthCodeExpired() throws Exception {
String ac = "2511725b-094f-4d1f-a7f7-da6003a5f8e7";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"123456\",\"sub\":\"" + ac + "\",\"iat\":1516239022}";
// add test data if not exists
AuthorizationCode acObject = new AuthorizationCode();
acObject.setAuthorizationCode(UUID.fromString(ac));
var existingAuthorizationCodeObject = authorizationCodeRepository
.getByAuthorizationCode(acObject.getAuthorizationCode())
.block();
if (existingAuthorizationCodeObject == null) {
log.info("Adding test case data");
acObject.setTaxReturnUuid(UUID.randomUUID());
acObject.setTaxYear(2022);
Timestamp expireAt = Timestamp.from(Instant.now().minus(Period.ofDays(30)));
acObject.setExpiresAt(expireAt);
acObject.setStateCode("FS");
acObject.setSubmissionId("someSubmissionId");
authorizationCodeRepository.save(acObject).block();
} else {
log.info("Test case data already exists: {}", existingAuthorizationCodeObject);
}
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "123456");
headers.set("Authorization", "Bearer " + jwtToken);
var exportRequest = new HttpEntity<>(headers);
var exportResponse = restTemplate.exchange(EXPORT_RETURN_URL, HttpMethod.GET, exportRequest, String.class);
String responseBody = exportResponse.getBody();
assertNotNull(responseBody);
assertTrue(responseBody.startsWith("{\"status\":\"error\""));
assertTrue(responseBody.contains("E_AUTHORIZATION_CODE_EXPIRED"));
log.info("Test succeeded with expected error: E_AUTHORIZATION_CODE_EXPIRED");
}
@Test
public void integrationTests_AuthCodeInvalidFormat() throws Exception {
String ac = "11df-4b5e-808f-bc48a9f4870b";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"123456\",\"sub\":\"" + ac + "\",\"iat\":1516239022}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "123456");
headers.set("Authorization", "Bearer " + jwtToken);
var exportRequestEntity = new HttpEntity<>(headers);
var exportResponse =
restTemplate.exchange(EXPORT_RETURN_URL, HttpMethod.GET, exportRequestEntity, ExportResponse.class);
ExportResponse responseBody = exportResponse.getBody();
assertNotNull(responseBody);
assertEquals("error", responseBody.getStatus());
assertEquals("E_AUTHORIZATION_CODE_INVALID_FORMAT", responseBody.getError());
log.info("Test succeeded with expected error: E_AUTHORIZATION_CODE_INVALID_FORMAT");
}
@Test
public void integrationTests_AuthCodeNotExist() throws Exception {
String ac = "d6e34be4-11df-4b5e-808f-bc48a9f4870c";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"123456\",\"sub\":\"" + ac + "\",\"iat\":1516239022}";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, PRIVATE_KEY_PATH);
// Set the desired header(s)
headers.set("account-id", "123456");
headers.set("Authorization", "Bearer " + jwtToken);
var exportRequestEntity = new HttpEntity<>(headers);
var exportResponse =
restTemplate.exchange(EXPORT_RETURN_URL, HttpMethod.GET, exportRequestEntity, ExportResponse.class);
ExportResponse responseBody = exportResponse.getBody();
assertNotNull(responseBody);
assertEquals("error", responseBody.getStatus());
assertEquals("E_AUTHORIZATION_CODE_NOT_EXIST", responseBody.getError());
log.info("Test succeeded with expected error: E_AUTHORIZATION_CODE_NOT_EXIST");
}
@Test
public void integrationTests_JWTVerificationFailed() throws Exception {
String ac = "d6e34be4-11df-4b5e-808f-bc48a9f4870c";
String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
String payload = "{\"iss\":\"123456\",\"sub\":\"" + ac + "\",\"iat\":1516239022}";
var badPrivateKeyPath = "src/test/resources/certificates/fakestate_bad.key";
// Create the JWT
String jwtToken = JwtSigner.createJwt(header, payload, badPrivateKeyPath);
// Set the desired header(s)
headers.set("account-id", "123456");
headers.set("Authorization", "Bearer " + jwtToken);
var exportRequestEntity = new HttpEntity<>(headers);
var exportResponse =
restTemplate.exchange(EXPORT_RETURN_URL, HttpMethod.GET, exportRequestEntity, ExportResponse.class);
ExportResponse responseBody = exportResponse.getBody();
assertNotNull(responseBody);
assertEquals("error", responseBody.getStatus());
assertEquals("E_JWT_VERIFICATION_FAILED", responseBody.getError());
log.info("Test succeeded with expected error: E_JWT_VERIFICATION_FAILED");
}
}

View file

@ -0,0 +1,95 @@
package gov.irs.directfile.stateapi.audit;
import java.util.List;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import io.netty.handler.codec.http.HttpResponseStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import gov.irs.directfile.stateapi.events.Event;
import gov.irs.directfile.stateapi.events.EventId;
import gov.irs.directfile.stateapi.events.EventStatus;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ExtendWith(MockitoExtension.class)
public class AuditServiceTest {
private AuditService auditSvc = new AuditService();
private Logger auditLogger;
private ListAppender<ILoggingEvent> listAppender;
@BeforeEach
public void setup() {
auditLogger = (Logger) LoggerFactory.getLogger(AuditService.class);
listAppender = new ListAppender<>();
listAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory());
listAppender.start();
auditLogger.addAppender(listAppender);
}
@AfterEach
public void tearDown() {
auditLogger.detachAppender(listAppender);
}
@Test
public void testLogEvent_success() throws Exception {
Event event = createAuditEvent(
EventId.CREATE_AUTHORIZATION_CODE, EventStatus.SUCCESS, HttpResponseStatus.OK.toString(), null);
auditSvc.logEvent(event);
List<ILoggingEvent> logsList = listAppender.list;
assertEquals(Level.INFO, logsList.get(0).getLevel());
assertEquals(logsList.get(0).getMDCPropertyMap().get("eventId"), EventId.CREATE_AUTHORIZATION_CODE.toString());
assertEquals(logsList.get(0).getMDCPropertyMap().get("eventStatus"), EventStatus.SUCCESS.toString());
assertEquals(logsList.get(0).getMDCPropertyMap().get("responseStatusCode"), HttpResponseStatus.OK.toString());
}
@Test
public void testLogEvent_failure() throws Exception {
String errMsg = "Internal Server Error";
Event event = createAuditEvent(
EventId.CREATE_AUTHORIZATION_CODE,
EventStatus.FAILURE,
HttpResponseStatus.INTERNAL_SERVER_ERROR.toString(),
errMsg);
auditSvc.logEvent(event);
List<ILoggingEvent> logsList = listAppender.list;
assertEquals(Level.ERROR, logsList.get(0).getLevel());
assertEquals(logsList.get(0).getMDCPropertyMap().get("eventId"), EventId.CREATE_AUTHORIZATION_CODE.toString());
assertEquals(logsList.get(0).getMDCPropertyMap().get("eventStatus"), EventStatus.FAILURE.toString());
assertEquals(
logsList.get(0).getMDCPropertyMap().get("responseStatusCode"),
HttpResponseStatus.INTERNAL_SERVER_ERROR.toString());
assertEquals(logsList.get(0).getMDCPropertyMap().get("eventErrorMessage"), errMsg);
}
private Event createAuditEvent(EventId id, EventStatus eventStatus, String responseStatus, String errorMsg) {
Event event = Event.builder()
.eventId(id)
.eventStatus(eventStatus)
.responseStatusCode(responseStatus)
.eventErrorMessage(errorMsg)
.taxPeriod("2022")
.userType("SYS")
.stateId("FS")
.remoteAddress("127.0.0.1")
.build();
return event;
}
}

View file

@ -0,0 +1,100 @@
package gov.irs.directfile.stateapi.authorization;
import java.io.IOException;
import java.text.ParseException;
import java.util.Base64;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import reactor.test.StepVerifier;
import gov.irs.directfile.models.encryption.DataEncryptDecrypt;
import gov.irs.directfile.stateapi.model.AuthCodeRequest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@ActiveProfiles({"test", "token-integration-test"})
public class AuthorizationTokenServiceIntegrationTest {
private final ObjectMapper mapper = new ObjectMapper();
@Autowired
AuthorizationTokenService authorizationTokenService;
@Autowired
DataEncryptDecrypt dataEncryptDecrypt;
@Test
public void givenTokenWithEncryptedTaxReturnClaims_whenClaimsDecrypted_thenClaimsMatchOriginalRequest() {
// given
AuthCodeRequest authCodeRequest =
new AuthCodeRequest(UUID.randomUUID(), "123-00-4567", 2023, "MA", "123456789AB");
AuthorizationTokenClaims claimsMap = mapper.convertValue(authCodeRequest, AuthorizationTokenClaims.class);
StepVerifier.create(authorizationTokenService.generateAndEncrypt(claimsMap))
.assertNext((token) -> {
try {
// when
byte[] ciphertext = Base64.getUrlDecoder().decode(token);
byte[] decrypted = dataEncryptDecrypt.decrypt(ciphertext);
SignedJWSParts signedJWSParts = mapper.readValue(decrypted, SignedJWSParts.class);
String signedJWS =
String.join(".", signedJWSParts.s1(), signedJWSParts.s2(), signedJWSParts.s3());
SignedJWT signedJWT = SignedJWT.parse(signedJWS);
JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet();
AuthorizationTokenClaims decryptedClaims = mapper.convertValue(
jwtClaimsSet.toJSONObject().get("tax-return-export-metadata"),
AuthorizationTokenClaims.class);
// then
assertEquals(authCodeRequest.getTaxReturnUuid().toString(), decryptedClaims.taxReturnUuid());
assertEquals(authCodeRequest.getTaxYear(), decryptedClaims.taxYear());
assertEquals(authCodeRequest.getStateCode(), decryptedClaims.stateCode());
assertEquals(authCodeRequest.getSubmissionId(), decryptedClaims.submissionId());
} catch (ParseException | IOException e) {
throw new RuntimeException(e);
}
})
.expectComplete()
.verify();
}
@Test
public void givenASignedToken_whenVerifyingSignature_thenTokenSignatureIsValid() {
// given
AuthCodeRequest authCodeRequest =
new AuthCodeRequest(UUID.randomUUID(), "123-00-4567", 2023, "MA", "123456789AB");
AuthorizationTokenClaims claimsMap = mapper.convertValue(authCodeRequest, AuthorizationTokenClaims.class);
StepVerifier.create(authorizationTokenService.generateAndEncrypt(claimsMap))
.assertNext((token) -> {
try {
// when
JWSVerifier signatureVerifier = new MACVerifier("GTc+SlI7C7ECPHAhAvIWqn2yAvzAGMVj");
byte[] ciphertext = Base64.getUrlDecoder().decode(token);
byte[] decrypted = dataEncryptDecrypt.decrypt(ciphertext);
SignedJWSParts signedJWSParts = mapper.readValue(decrypted, SignedJWSParts.class);
String signedJWS =
String.join(".", signedJWSParts.s1(), signedJWSParts.s2(), signedJWSParts.s3());
SignedJWT signedJWT = SignedJWT.parse(signedJWS);
// then
assertTrue(signedJWT.verify(signatureVerifier));
} catch (ParseException | JOSEException | IOException e) {
throw new RuntimeException(e);
}
})
.expectComplete()
.verify();
}
}

View file

@ -0,0 +1,209 @@
package gov.irs.directfile.stateapi.authorization;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.time.Instant;
import java.util.*;
import com.amazonaws.encryptionsdk.exception.AwsCryptoException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import lombok.SneakyThrows;
import org.junit.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.test.StepVerifier;
import gov.irs.directfile.models.encryption.DataEncryptDecrypt;
import gov.irs.directfile.stateapi.model.AuthCodeRequest;
import static gov.irs.directfile.stateapi.authorization.AuthorizationTokenService.EXPORT_CLAIM_KEY;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class AuthorizationTokenServiceTest {
private static final String TEST_SIGNING_KEY = "GTc+SlI7C7ECPHAhAvIWqn2yAvzAGMVj";
private final ObjectMapper mapper = new ObjectMapper();
public AuthorizationTokenService initializeService(DataEncryptDecrypt dataEncryptDecrypt) {
return new AuthorizationTokenService(dataEncryptDecrypt, TEST_SIGNING_KEY, 60);
}
private AuthorizationTokenClaims setupClaims() {
AuthCodeRequest authCodeRequest =
new AuthCodeRequest(UUID.randomUUID(), "123-00-4567", 2023, "MA", "123456789AB");
return mapper.convertValue(authCodeRequest, AuthorizationTokenClaims.class);
}
@SneakyThrows
private byte[] setupSignedJwt(AuthorizationTokenClaims claims) {
JWSSigner signer = new MACSigner(TEST_SIGNING_KEY);
Instant issuedAt = Instant.now();
JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
.claim(EXPORT_CLAIM_KEY, claims)
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(issuedAt.plusSeconds(60)))
.build();
JWSObject jwsObject = new JWSObject(
new JWSHeader.Builder(JWSAlgorithm.HS256).build(), new Payload(jwtClaimsSet.toJSONObject()));
jwsObject.sign(signer);
String signedJwt = jwsObject.serialize();
return signedJwt.getBytes(StandardCharsets.UTF_8);
}
@Test
public void givenTaxReturnSubmissionClaims_whenGeneratingAuthorizationToken_thenEncryptorIsCalled() {
// given
DataEncryptDecrypt dataEncryptDecrypt = mock(DataEncryptDecrypt.class);
AuthorizationTokenService authorizationTokenService = initializeService(dataEncryptDecrypt);
AuthorizationTokenClaims authorizationTokenClaims = setupClaims();
// when
// NOTE: We aren't stubbing the encryptor's return value, so a NullPointerException will be thrown.
// That's ok because we're not testing what the encryptor returns,
// we're just testing that the encryptor is invoked
assertThrows(NullPointerException.class, () -> authorizationTokenService
.generateAndEncrypt(authorizationTokenClaims)
.block());
// then
verify(dataEncryptDecrypt).encrypt(any(), any());
}
@Test
public void
givenTaxReturnSubmissionClaims_whenGeneratingAuthorizationToken_thenEncryptorIsCalledWithProperContext() {
// given
DataEncryptDecrypt dataEncryptDecrypt = mock(DataEncryptDecrypt.class);
AuthorizationTokenService authorizationTokenService = initializeService(dataEncryptDecrypt);
AuthorizationTokenClaims authorizationTokenClaims = setupClaims();
// when
// NOTE: We aren't stubbing the encryptor's return value, so a NullPointerException will be thrown.
// That's ok because we're not testing what the encryptor returns,
// we're just testing that an encryption context is passed into the encryptor
assertThrows(NullPointerException.class, () -> authorizationTokenService
.generateAndEncrypt(authorizationTokenClaims)
.block());
ArgumentCaptor<Map<String, String>> encryptionContextCaptor = ArgumentCaptor.captor();
// then
verify(dataEncryptDecrypt).encrypt(any(), encryptionContextCaptor.capture());
assertEquals(Map.of("system", "DIRECT-FILE", "type", "STATE-API"), encryptionContextCaptor.getValue());
}
@Test
public void givenGeneratingAuthorizationToken_whenAnExceptionIsThrown_thenItPropagates() {
// given
DataEncryptDecrypt dataEncryptDecrypt = mock(DataEncryptDecrypt.class);
AuthorizationTokenService authorizationTokenService = initializeService(dataEncryptDecrypt);
// when
when(dataEncryptDecrypt.encrypt(any(), any())).thenThrow(AwsCryptoException.class);
AuthorizationTokenClaims claimsMap = setupClaims();
// then
StepVerifier.create(authorizationTokenService.generateAndEncrypt(claimsMap))
.expectError(AwsCryptoException.class)
.verify();
}
@Test
public void givenTaxReturnSubmissionClaims_whenGeneratingAuthorizationToken_thenTokenIsSignedWithProperKey() {
// given
DataEncryptDecrypt dataEncryptDecrypt = mock(DataEncryptDecrypt.class);
AuthorizationTokenService authorizationTokenService = initializeService(dataEncryptDecrypt);
AuthorizationTokenClaims tokenClaims = setupClaims();
byte[] signedJwtBytes = setupSignedJwt(tokenClaims);
when(dataEncryptDecrypt.encrypt(any(), any())).thenReturn(signedJwtBytes);
// when
authorizationTokenService.generateAndEncrypt(tokenClaims).block();
// then
ArgumentCaptor<byte[]> signedTokenCaptor = ArgumentCaptor.captor();
verify(dataEncryptDecrypt).encrypt(signedTokenCaptor.capture(), anyMap());
byte[] signedTokenArgument = signedTokenCaptor.getValue();
try {
JWSVerifier signatureVerifier = new MACVerifier(TEST_SIGNING_KEY);
SignedJWSParts signedJWSParts = mapper.readValue(signedTokenArgument, SignedJWSParts.class);
String signedJWS = String.join(".", signedJWSParts.s1(), signedJWSParts.s2(), signedJWSParts.s3());
SignedJWT signedJWT = SignedJWT.parse(signedJWS);
assertTrue(signedJWT.verify(signatureVerifier));
} catch (JOSEException | ParseException | IOException e) {
throw new RuntimeException(e);
}
}
@Test
public void
givenTaxReturnSubmissionClaims_whenGeneratingAuthorizationToken_thenTokenContainsEncryptedExportIdentifiers() {
DataEncryptDecrypt dataEncryptDecrypt = mock(DataEncryptDecrypt.class);
AuthorizationTokenService authorizationTokenService = initializeService(dataEncryptDecrypt);
// given
AuthorizationTokenClaims tokenClaims = setupClaims();
byte[] signedJwtBytes = setupSignedJwt(tokenClaims);
when(dataEncryptDecrypt.encrypt(any(), any())).thenReturn(signedJwtBytes);
// when
authorizationTokenService.generateAndEncrypt(tokenClaims).block();
// then
ArgumentCaptor<byte[]> signedTokenCaptor = ArgumentCaptor.captor();
verify(dataEncryptDecrypt).encrypt(signedTokenCaptor.capture(), anyMap());
byte[] signedTokenArgument = signedTokenCaptor.getValue();
try {
SignedJWSParts signedJWSParts = mapper.readValue(signedTokenArgument, SignedJWSParts.class);
String signedJWS = String.join(".", signedJWSParts.s1(), signedJWSParts.s2(), signedJWSParts.s3());
SignedJWT signedJWT = SignedJWT.parse(signedJWS);
JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet();
AuthorizationTokenClaims taxReturnExportClaim = mapper.convertValue(
jwtClaimsSet.toJSONObject().get("tax-return-export-metadata"), AuthorizationTokenClaims.class);
assertEquals(tokenClaims, taxReturnExportClaim);
} catch (ParseException | IOException e) {
throw new RuntimeException(e);
}
}
@Test
public void givenTaxReturnSubmissionClaims_whenGeneratingAuthorizationToken_thenExpirationIsSet() {
DataEncryptDecrypt dataEncryptDecrypt = mock(DataEncryptDecrypt.class);
AuthorizationTokenService authorizationTokenService = initializeService(dataEncryptDecrypt);
// given
AuthorizationTokenClaims tokenClaims = setupClaims();
byte[] signedJwtBytes = setupSignedJwt(tokenClaims);
when(dataEncryptDecrypt.encrypt(any(), any())).thenReturn(signedJwtBytes);
// when
authorizationTokenService.generateAndEncrypt(tokenClaims).block();
// then
ArgumentCaptor<byte[]> signedTokenCaptor = ArgumentCaptor.captor();
verify(dataEncryptDecrypt).encrypt(signedTokenCaptor.capture(), anyMap());
byte[] signedTokenArgument = signedTokenCaptor.getValue();
try {
SignedJWSParts signedJWSParts = mapper.readValue(signedTokenArgument, SignedJWSParts.class);
String signedJWS = String.join(".", signedJWSParts.s1(), signedJWSParts.s2(), signedJWSParts.s3());
SignedJWT signedJWT = SignedJWT.parse(signedJWS);
JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet();
assertTrue(jwtClaimsSet.getExpirationTime().after(Date.from(Instant.now())));
assertTrue(jwtClaimsSet
.getExpirationTime()
.before(Date.from(Instant.now().plusSeconds(60))));
} catch (ParseException | IOException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,52 @@
package gov.irs.directfile.stateapi.configuration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
class AwsCredentialsConfigurationTest {
@Test
void staticCredentialProviderCreatedWhenApplicablePropertySetToFalse() {
ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner()
.withPropertyValues(
"aws.s3.accessKey=test",
"aws.s3.secretKey=test",
"aws.s3.default-credentials-provider-chain-enabled=false")
.withUserConfiguration(AwsCredentialsConfiguration.class);
applicationContextRunner.run((context) -> {
assertThat(context.getBean(AwsCredentialsProvider.class)).isNotNull();
assertThat(context.getBean(AwsCredentialsProvider.class)).isInstanceOf(StaticCredentialsProvider.class);
});
}
@Test
void staticCredentialProviderCreatedWhenApplicablePropertyIsNotSet() {
ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner()
.withPropertyValues("aws.s3.accessKey=test", "aws.s3.secretKey=test")
.withUserConfiguration(AwsCredentialsConfiguration.class);
applicationContextRunner.run((context) -> {
assertThat(context.getBean(AwsCredentialsProvider.class)).isNotNull();
assertThat(context.getBean(AwsCredentialsProvider.class)).isInstanceOf(StaticCredentialsProvider.class);
});
}
@Test
void defaultCredentialProviderCreatedWhenApplicablePropertySetToTrue() {
ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner()
.withPropertyValues(
"aws.s3.accessKey=test",
"aws.s3.secretKey=test",
"aws.s3.default-credentials-provider-chain-enabled=true")
.withUserConfiguration(AwsCredentialsConfiguration.class);
applicationContextRunner.run((context) -> {
assertThat(context.getBean(AwsCredentialsProvider.class)).isNotNull();
assertThat(context.getBean(AwsCredentialsProvider.class)).isInstanceOf(DefaultCredentialsProvider.class);
});
}
}

View file

@ -0,0 +1,32 @@
package gov.irs.directfile.stateapi.configuration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import software.amazon.awssdk.services.kms.KmsClient;
import software.amazon.encryption.s3.materials.CryptographicMaterialsManager;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(classes = {EncryptionClientConfiguration.class, AwsCredentialsConfiguration.class})
@ActiveProfiles("test")
class EncryptionClientConfigurationTest {
@Autowired
EncryptionClientConfiguration encryptionClientConfiguration;
@Test
void regionalKmsClientIsNotNull() {
KmsClient kmsClient = encryptionClientConfiguration.regionalKmsClient();
assertNotNull(kmsClient);
}
@Test
void kmsCryptoIsNotNull() {
String testKmsKeyArn = "test-kms-arn";
CryptographicMaterialsManager cryptographicMaterialsManager =
encryptionClientConfiguration.kmsCrypto(testKmsKeyArn);
assertNotNull(cryptographicMaterialsManager);
}
}

Some files were not shown because too many files have changed in this diff Show more