12 Commits

Author SHA1 Message Date
278ebf07fa commit 2026-07-26 19:54:40 +05:30
d92e3be5e8 commit 2026-07-26 19:54:37 +05:30
a0468d2e6a commit 2026-07-26 19:54:13 +05:30
c927de82ef commit 2026-07-26 19:54:08 +05:30
a4daf7e204 Machine Installation id based Oauth 2 2026-07-26 15:19:27 +05:30
dcb40473da Performance tuning done 2026-07-23 11:53:40 +05:30
4cbd510b85 Index to increase the performance 2026-07-21 22:07:45 +05:30
f0481d090c Performance tuning - backend 2026-07-21 21:32:53 +05:30
4f7c06ed97 UI Migration Done 2026-07-21 20:46:27 +05:30
46183766e5 Maximum UI changes fixed 2026-07-20 01:34:18 +05:30
b6c9ee4bf8 Maven and JDK 21 Migration 2026-07-16 22:35:34 +05:30
0080857d4e Initial Commit 2025-04-19 17:50:17 +05:30
2318 changed files with 839351 additions and 95 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

7
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/target/
/config/
!.mvn/wrapper/maven-wrapper.jar
### STS ###
@@ -18,7 +19,11 @@
### NetBeans ###
/nbproject/private/
/build/
/build/classes/*
!/build/WebContent/
!/build/WebContent/**
/build/WebContent/ReportsBackup/
/build/WebContent/WEB-INF/lib/
/nbbuild/
/dist/
/nbdist/

77
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,77 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "Cygnus Cloud Service (JDK 21)",
"request": "launch",
"mainClass": "com.cygnus.cloud.CygnusCloudServiceApplication",
"projectName": "cygnus-cloud-service",
"cwd": "${workspaceFolder}/cygnus-cloud-service",
"console": "integratedTerminal",
"env": {
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home",
"DB_HOST": "103.125.129.116",
"DB_PORT": "5333",
"DB_NAME": "matrix",
"DB_USER": "postgres",
"DB_PASSWORD": "M@triXPostgr3s@6202",
"DB_SSL": "false",
"REDIS_HOST": "103.125.129.116",
"REDIS_PORT": "7901",
"REDIS_PASSWORD": "M@triXR3d1s@6202",
"CYGNUS_CLOUD_PORT": "8090",
"CYGNUS_SECURITY_ENABLED": "true",
"CYGNUS_JWT_ISSUER_URI": "http://localhost:8090",
"CYGNUS_JWT_AUDIENCE": "cygnus-cloud-api",
"CYGNUS_TOKEN_AUDIENCE": "http://localhost:8090/oauth2/token",
"CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/assertion-decryption-private.pem",
"CYGNUS_ACCESS_TOKEN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem",
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml"
},
"shortenCommandLine": "argfile"
},
{
"type": "java",
"name": "Cygnus On-Prem - Tomcat 10 (JDK 21)",
"request": "launch",
"mainClass": "matrix.nimble.EmbeddedTomcatServer",
"cwd": "${workspaceFolder}/cygnus-onprem-app",
"console": "integratedTerminal",
"preLaunchTask": "cygnus: dev compile",
"classPaths": [
"$Test"
],
"env": {
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home",
"MATRIX_DB_URL": "jdbc:postgresql://103.125.129.116:5333/matrix",
"MATRIX_DB_USERNAME": "postgres",
"MATRIX_DB_PASSWORD": "M@triXPostgr3s@6202",
"REDIS_HOST": "103.125.129.116",
"REDIS_PORT": "7901",
"REDIS_PASSWORD": "M@triXR3d1s@6202",
"REDIS_SSL": "false",
"CYGNUS_CLOUD_BASE_URL": "http://localhost:8090",
"CYGNUS_TOKEN_URL": "http://localhost:8090/oauth2/token",
"CYGNUS_CLIENT_ID": "matrix",
"CYGNUS_INSTALLATION_ID": "matrix-delhi-cygnus-01",
"CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem",
"CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT10S",
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
},
"vmArgs": "-Dserver.port=8080 -Djava.awt.headless=true -Dmatrix.webapp=${workspaceFolder}/cygnus-onprem-app/build/WebContent -Dmatrix.classes=${workspaceFolder}/cygnus-onprem-app/target/classes",
"shortenCommandLine": "argfile"
},
{
"type": "java",
"name": "Current Java File",
"request": "launch",
"mainClass": "${file}"
}
]
}

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"java.compile.nullAnalysis.mode": "automatic"
}

24
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "cygnus: dev compile",
"type": "shell",
"command": "mvn",
"args": [
"test-compile",
"-DskipTests"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"
}
},
"group": "build",
"problemMatcher": [
"$javac"
]
}
]
}

View File

@@ -0,0 +1,359 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="3720" viewBox="0 0 1920 3720" role="img" aria-labelledby="title description">
<title id="title">Cygnus on-premises to cloud authentication architecture overview</title>
<desc id="description">Complete architecture, request flow, sequence diagram, security layers, code navigation, data access, session mapping, and failure behavior for Cygnus cloud identity authentication.</desc>
<defs>
<linearGradient id="pageBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#eaf2f8"/>
<stop offset="1" stop-color="#d9e7f1"/>
</linearGradient>
<linearGradient id="heroBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#173b5f"/>
<stop offset="1" stop-color="#28679b"/>
</linearGradient>
<linearGradient id="cloudBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e7f2fb"/>
<stop offset="1" stop-color="#d2e7f7"/>
</linearGradient>
<linearGradient id="onpremBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#eef8f2"/>
<stop offset="1" stop-color="#dcefe3"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#1c3448" flood-opacity=".16"/>
</filter>
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L12,6 L0,12 z" fill="#28679b"/>
</marker>
<marker id="arrowGreen" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L12,6 L0,12 z" fill="#26845b"/>
</marker>
<marker id="arrowOrange" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L12,6 L0,12 z" fill="#d47b17"/>
</marker>
<style>
text { font-family: Inter, "Segoe UI", Arial, sans-serif; fill: #22303d; }
.hero-title { font-size: 46px; font-weight: 750; fill: #fff; letter-spacing: .2px; }
.hero-sub { font-size: 22px; fill: #d7eafb; }
.section-title { font-size: 30px; font-weight: 750; fill: #173b5f; }
.section-sub { font-size: 17px; fill: #536777; }
.box-title { font-size: 20px; font-weight: 750; }
.box-sub { font-size: 14px; fill: #5a6f7f; }
.body { font-size: 16px; }
.small { font-size: 14px; }
.tiny { font-size: 12px; }
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 13px; }
.white { fill: #fff; }
.muted { fill: #64798a; }
.blue { fill: #28679b; }
.green { fill: #26845b; }
.orange { fill: #c76c0f; }
.red { fill: #b74646; }
.bold { font-weight: 700; }
.card { fill: #fff; stroke: #b8cad8; stroke-width: 1.4; rx: 12; filter: url(#shadow); }
.soft-card { fill: #f7fafc; stroke: #c5d5e1; stroke-width: 1.2; rx: 10; }
.onprem { fill: url(#onpremBg); stroke: #83b99b; stroke-width: 1.5; rx: 12; }
.cloud { fill: url(#cloudBg); stroke: #81b2d5; stroke-width: 1.5; rx: 12; }
.security { fill: #fff6e7; stroke: #e2ae62; stroke-width: 1.4; rx: 10; }
.database { fill: #f2ecfa; stroke: #a991c6; stroke-width: 1.4; rx: 10; }
.line-blue { stroke: #28679b; stroke-width: 3; fill: none; marker-end: url(#arrowBlue); }
.line-green { stroke: #26845b; stroke-width: 3; fill: none; marker-end: url(#arrowGreen); }
.line-orange { stroke: #d47b17; stroke-width: 3; fill: none; marker-end: url(#arrowOrange); }
.dash { stroke-dasharray: 8 7; }
.lane { stroke: #9bb0bf; stroke-width: 1.5; stroke-dasharray: 6 6; }
.step-circle { fill: #28679b; stroke: #fff; stroke-width: 3; }
.step-number { font-size: 14px; font-weight: 750; fill: #fff; text-anchor: middle; dominant-baseline: central; }
.chip { fill: #e5eff7; stroke: #aec4d5; stroke-width: 1; rx: 14; }
</style>
</defs>
<rect width="1920" height="3720" fill="url(#pageBg)"/>
<!-- Hero -->
<rect x="50" y="42" width="1820" height="190" rx="20" fill="url(#heroBg)" filter="url(#shadow)"/>
<text x="105" y="112" class="hero-title">Cygnus Architecture Overview</text>
<text x="105" y="157" class="hero-sub">Secure on-premises → cloud identity authentication while operational and applicant data remain on customer infrastructure</text>
<rect x="105" y="180" width="212" height="30" class="chip" opacity=".22"/>
<text x="123" y="201" class="small white bold">JDK 21 · Spring MVC</text>
<rect x="330" y="180" width="245" height="30" class="chip" opacity=".22"/>
<text x="348" y="201" class="small white bold">Spring WebFlux client</text>
<rect x="588" y="180" width="280" height="30" class="chip" opacity=".22"/>
<text x="606" y="201" class="small white bold">Spring Boot cloud service</text>
<rect x="881" y="180" width="260" height="30" class="chip" opacity=".22"/>
<text x="899" y="201" class="small white bold">PostgreSQL + Vert.x</text>
<rect x="1154" y="180" width="212" height="30" class="chip" opacity=".22"/>
<text x="1172" y="201" class="small white bold">Redis replay guard</text>
<!-- Architecture overview -->
<text x="70" y="292" class="section-title">1 · Architecture overview</text>
<text x="70" y="322" class="section-sub">The browser-facing application remains on-premises. Authentication and menu ownership are delegated to the cloud through a reusable client.</text>
<rect x="70" y="352" width="1780" height="490" class="card"/>
<rect x="95" y="377" width="1050" height="440" class="onprem"/>
<text x="125" y="414" class="box-title green">CUSTOMER PREMISES</text>
<text x="125" y="439" class="box-sub">Applicant PII, punching, operations, reporting/MIS, JSP UI and HTTP session</text>
<rect x="130" y="480" width="210" height="126" class="soft-card"/>
<text x="235" y="520" class="box-title" text-anchor="middle">Browser</text>
<text x="235" y="550" class="small" text-anchor="middle">Login form / JSP UI</text>
<text x="235" y="576" class="tiny muted" text-anchor="middle">POST /ver/authenticatelogin</text>
<rect x="430" y="455" width="300" height="178" class="soft-card"/>
<text x="580" y="495" class="box-title" text-anchor="middle">cygnus-onprem-app</text>
<text x="580" y="524" class="small" text-anchor="middle">Spring MVC + embedded Tomcat</text>
<text x="580" y="552" class="tiny muted" text-anchor="middle">SessionController</text>
<text x="580" y="575" class="tiny muted" text-anchor="middle">CloudAuthenticationGateway</text>
<text x="580" y="598" class="tiny muted" text-anchor="middle">CloudSessionMapper</text>
<rect x="820" y="455" width="290" height="178" class="security"/>
<text x="965" y="495" class="box-title" text-anchor="middle">cygnus-cloud-client</text>
<text x="965" y="524" class="small" text-anchor="middle">Reusable WebClient gateway</text>
<text x="965" y="552" class="tiny muted" text-anchor="middle">OAuthMachineTokenProvider</text>
<text x="965" y="575" class="tiny muted" text-anchor="middle">LoginEnvelopeEncryptor</text>
<text x="965" y="598" class="tiny muted" text-anchor="middle">CloudIdentityClient</text>
<path d="M340 543 H420" class="line-green"/>
<text x="380" y="528" class="tiny green" text-anchor="middle">credentials</text>
<path d="M730 543 H810" class="line-green"/>
<text x="770" y="528" class="tiny green" text-anchor="middle">delegate</text>
<rect x="130" y="676" width="980" height="102" class="soft-card"/>
<text x="155" y="711" class="box-title">Existing on-premises application behavior retained</text>
<text x="155" y="741" class="small">Cloud response → legacy SessionValues + menu HTML → existing authorization, controllers, JSP pages and operational database access.</text>
<rect x="1190" y="377" width="635" height="440" class="cloud"/>
<text x="1220" y="414" class="box-title blue">CYGNUS CLOUD</text>
<text x="1220" y="439" class="box-sub">Vendor-controlled machine authorization, identity, permissions and menu data</text>
<rect x="1225" y="470" width="260" height="140" class="security"/>
<text x="1355" y="507" class="box-title" text-anchor="middle">OAuth endpoint</text>
<text x="1355" y="536" class="small" text-anchor="middle">POST /oauth2/token</text>
<text x="1355" y="564" class="tiny muted" text-anchor="middle">Validate nested machine assertion</text>
<text x="1355" y="586" class="tiny muted" text-anchor="middle">Issue short-lived RS256 JWT</text>
<rect x="1525" y="470" width="265" height="140" class="cloud"/>
<text x="1657" y="507" class="box-title" text-anchor="middle">Identity API</text>
<text x="1657" y="536" class="small" text-anchor="middle">POST /api/v1/identity/login</text>
<text x="1657" y="564" class="tiny muted" text-anchor="middle">Decrypt login envelope</text>
<text x="1657" y="586" class="tiny muted" text-anchor="middle">Authenticate and load menu</text>
<rect x="1225" y="660" width="260" height="110" class="database"/>
<text x="1355" y="699" class="box-title" text-anchor="middle">Redis</text>
<text x="1355" y="728" class="small" text-anchor="middle">Nonce replay protection</text>
<text x="1355" y="751" class="tiny muted" text-anchor="middle">Atomic claim + short TTL</text>
<rect x="1525" y="660" width="265" height="110" class="database"/>
<text x="1657" y="699" class="box-title" text-anchor="middle">PostgreSQL</text>
<text x="1657" y="728" class="small" text-anchor="middle">matrix.identity schema</text>
<text x="1657" y="751" class="tiny muted" text-anchor="middle">Vert.x prepared queries</text>
<path d="M1110 520 C1160 520 1160 520 1215 520" class="line-blue"/>
<text x="1162" y="504" class="tiny blue" text-anchor="middle">machine assertion</text>
<path d="M1110 585 C1320 625 1420 570 1515 540" class="line-blue"/>
<text x="1320" y="605" class="tiny blue" text-anchor="middle">Bearer JWT + encrypted login</text>
<path d="M1657 610 V650" class="line-blue"/>
<path d="M1600 610 C1515 640 1450 670 1490 710" class="line-blue"/>
<!-- Responsibility split -->
<text x="70" y="912" class="section-title">2 · Responsibility and data boundary</text>
<text x="70" y="942" class="section-sub">The split is deliberate: identity and non-PI control data can be cloud-managed without transferring loan-applicant PII.</text>
<rect x="70" y="972" width="855" height="265" class="card"/>
<text x="105" y="1014" class="box-title green">Remains on customer premises</text>
<text x="115" y="1054" class="body">• Punching and applicant intake</text>
<text x="115" y="1086" class="body">• Operational allocation, scanning, telecalling and reports</text>
<text x="115" y="1118" class="body">• Applicant PII, addresses, documents, photos and verification content</text>
<text x="115" y="1150" class="body">• MIS generation and existing JSP/HTTP-session application behavior</text>
<text x="115" y="1182" class="body">• Existing operational SQL, functions and views unrelated to cloud identity</text>
<rect x="945" y="972" width="905" height="265" class="card"/>
<text x="980" y="1014" class="box-title blue">Managed by Cygnus cloud</text>
<text x="990" y="1054" class="body">• Customer installation identity and machine authorization</text>
<text x="990" y="1086" class="body">• Application users, groups, companies and branches required at login</text>
<text x="990" y="1118" class="body">• Page permissions, denied pages and menu structure</text>
<text x="990" y="1150" class="body">• Authentication audit entry and cloud access-token issuance</text>
<text x="990" y="1182" class="body">• Redis nonce state used to reject replayed login envelopes</text>
<!-- Full runtime sequence -->
<text x="70" y="1307" class="section-title">3 · Full authentication sequence</text>
<text x="70" y="1337" class="section-sub">Every arrow below is part of one browser login. The access token is cached and reused until shortly before expiry.</text>
<rect x="70" y="1367" width="1780" height="1040" class="card"/>
<!-- Lane headers -->
<rect x="90" y="1390" width="220" height="62" class="soft-card"/>
<rect x="385" y="1390" width="245" height="62" class="onprem"/>
<rect x="705" y="1390" width="245" height="62" class="security"/>
<rect x="1025" y="1390" width="230" height="62" class="security"/>
<rect x="1330" y="1390" width="240" height="62" class="cloud"/>
<rect x="1640" y="1390" width="185" height="62" class="database"/>
<text x="200" y="1428" class="box-title" text-anchor="middle">Browser</text>
<text x="507" y="1428" class="box-title" text-anchor="middle">On-prem MVC</text>
<text x="827" y="1428" class="box-title" text-anchor="middle">Cloud client</text>
<text x="1140" y="1428" class="box-title" text-anchor="middle">OAuth service</text>
<text x="1450" y="1428" class="box-title" text-anchor="middle">Identity service</text>
<text x="1732" y="1428" class="box-title" text-anchor="middle">Redis / DB</text>
<line x1="200" y1="1452" x2="200" y2="2365" class="lane"/>
<line x1="507" y1="1452" x2="507" y2="2365" class="lane"/>
<line x1="827" y1="1452" x2="827" y2="2365" class="lane"/>
<line x1="1140" y1="1452" x2="1140" y2="2365" class="lane"/>
<line x1="1450" y1="1452" x2="1450" y2="2365" class="lane"/>
<line x1="1732" y1="1452" x2="1732" y2="2365" class="lane"/>
<!-- Sequence messages -->
<circle cx="95" cy="1500" r="15" class="step-circle"/><text x="95" y="1500" class="step-number">1</text>
<path d="M200 1500 H497" class="line-green"/>
<text x="348" y="1483" class="small bold" text-anchor="middle">POST login ID + password</text>
<text x="348" y="1523" class="tiny muted" text-anchor="middle">/ver/authenticatelogin</text>
<circle cx="95" cy="1575" r="15" class="step-circle"/><text x="95" y="1575" class="step-number">2</text>
<path d="M507 1575 H817" class="line-green"/>
<text x="662" y="1558" class="small bold" text-anchor="middle">CloudAuthenticationGateway.authenticate()</text>
<text x="662" y="1598" class="tiny muted" text-anchor="middle">Synchronous MVC → reactive client boundary</text>
<circle cx="95" cy="1650" r="15" class="step-circle"/><text x="95" y="1650" class="step-number">3</text>
<path d="M827 1650 H1130" class="line-orange"/>
<text x="978" y="1633" class="small bold" text-anchor="middle">POST machine assertion</text>
<text x="978" y="1673" class="tiny muted" text-anchor="middle">Only when cached access token is absent/expiring</text>
<circle cx="95" cy="1725" r="15" class="step-circle"/><text x="95" y="1725" class="step-number">4</text>
<path d="M1140 1725 H1440" class="line-orange"/>
<text x="1290" y="1708" class="small bold" text-anchor="middle">Decrypt JWE + verify signed inner JWT</text>
<text x="1290" y="1748" class="tiny muted" text-anchor="middle">Validate client ID, installation, audience and expiry</text>
<circle cx="95" cy="1800" r="15" class="step-circle"/><text x="95" y="1800" class="step-number">5</text>
<path d="M1140 1800 H837" class="line-orange"/>
<text x="988" y="1783" class="small bold" text-anchor="middle">Return short-lived RS256 access JWT</text>
<text x="988" y="1823" class="tiny muted" text-anchor="middle">Claims: client_id, installation_id, scope, issuer, audience, expiry</text>
<circle cx="95" cy="1885" r="15" class="step-circle"/><text x="95" y="1885" class="step-number">6</text>
<path d="M827 1885 H1440" class="line-blue"/>
<text x="1133" y="1868" class="small bold" text-anchor="middle">Bearer JWT + encrypted login envelope</text>
<text x="1133" y="1908" class="tiny muted" text-anchor="middle">AES-256-GCM payload; AES key wrapped by RSA-OAEP-256</text>
<circle cx="95" cy="1970" r="15" class="step-circle"/><text x="95" y="1970" class="step-number">7</text>
<path d="M1450 1970 H1722" class="line-blue"/>
<text x="1586" y="1953" class="small bold" text-anchor="middle">Atomically claim nonce in Redis</text>
<text x="1586" y="1993" class="tiny muted" text-anchor="middle">Reject duplicate; apply short TTL</text>
<circle cx="95" cy="2045" r="15" class="step-circle"/><text x="95" y="2045" class="step-number">8</text>
<path d="M1450 2045 H1722" class="line-blue"/>
<text x="1586" y="2028" class="small bold" text-anchor="middle">Vert.x prepared SQL queries</text>
<text x="1586" y="2068" class="tiny muted" text-anchor="middle">User → password check → menu → login audit</text>
<circle cx="95" cy="2120" r="15" class="step-circle"/><text x="95" y="2120" class="step-number">9</text>
<path d="M1450 2120 H837" class="line-blue"/>
<text x="1143" y="2103" class="small bold" text-anchor="middle">Authenticated identity + structured menu</text>
<text x="1143" y="2143" class="tiny muted" text-anchor="middle">User, group, company, branch, permissions and login time</text>
<circle cx="95" cy="2195" r="15" class="step-circle"/><text x="95" y="2195" class="step-number">10</text>
<path d="M827 2195 H517" class="line-green"/>
<text x="672" y="2178" class="small bold" text-anchor="middle">Map cloud response to legacy SessionValues</text>
<text x="672" y="2218" class="tiny muted" text-anchor="middle">Render menu HTML and preserve existing JSP expectations</text>
<circle cx="95" cy="2270" r="15" class="step-circle"/><text x="95" y="2270" class="step-number">11</text>
<path d="M507 2270 H210" class="line-green"/>
<text x="358" y="2253" class="small bold" text-anchor="middle">Store HTTP session and return home</text>
<text x="358" y="2293" class="tiny muted" text-anchor="middle">Existing authorization and UI flow continues</text>
<rect x="118" y="2330" width="1684" height="52" class="security"/>
<text x="960" y="2362" class="small bold" text-anchor="middle">Cloud identity is mandatory: there is no local password fallback. A cloud outage returns a controlled temporary-unavailable login message.</text>
<!-- Security model -->
<text x="70" y="2477" class="section-title">4 · Security model</text>
<text x="70" y="2507" class="section-sub">Machine identity, transport security, application-layer encryption and replay protection are separate layers.</text>
<rect x="70" y="2537" width="1780" height="410" class="card"/>
<rect x="95" y="2570" width="405" height="330" class="security"/>
<text x="125" y="2610" class="box-title orange">A · Machine assertion</text>
<text x="125" y="2645" class="small">1. Inner JWT signed by installation private key</text>
<text x="125" y="2674" class="small">2. Identifies client + installation</text>
<text x="125" y="2703" class="small">3. Nested inside RSA-OAEP-256 JWE</text>
<text x="125" y="2732" class="small">4. Cloud decrypts with assertion private key</text>
<text x="125" y="2761" class="small">5. Signature checked using registered client key</text>
<text x="125" y="2790" class="small">6. Assertion may be long-lived and is used only</text>
<text x="143" y="2816" class="small">to obtain short-lived access tokens</text>
<rect x="125" y="2845" width="335" height="34" class="chip"/>
<text x="292" y="2868" class="tiny bold" text-anchor="middle">Customer-specific asymmetric identity</text>
<rect x="530" y="2570" width="405" height="330" class="security"/>
<text x="560" y="2610" class="box-title orange">B · Access token</text>
<text x="560" y="2645" class="small">• RS256 signed by the cloud</text>
<text x="560" y="2674" class="small">• Short expiry; cached until refresh skew</text>
<text x="560" y="2703" class="small">• Bound to client ID and installation ID</text>
<text x="560" y="2732" class="small">• Carries identity.login scope</text>
<text x="560" y="2761" class="small">• Issuer and audience validated</text>
<text x="560" y="2790" class="small">• Required by Spring Security before the</text>
<text x="578" y="2816" class="small">cloud identity controller executes</text>
<rect x="560" y="2845" width="335" height="34" class="chip"/>
<text x="727" y="2868" class="tiny bold" text-anchor="middle">Machine authorization, not user session</text>
<rect x="965" y="2570" width="405" height="330" class="security"/>
<text x="995" y="2610" class="box-title orange">C · Login envelope</text>
<text x="995" y="2645" class="small">• Fresh random AES-256 key per request</text>
<text x="995" y="2674" class="small">• Credentials encrypted with AES-GCM</text>
<text x="995" y="2703" class="small">• AES key encrypted by cloud RSA key</text>
<text x="995" y="2732" class="small">• GCM detects ciphertext tampering</text>
<text x="995" y="2761" class="small">• Payload contains nonce and timestamp</text>
<text x="995" y="2790" class="small">• Client/installation values must match</text>
<text x="1013" y="2816" class="small">the authenticated access-token claims</text>
<rect x="995" y="2845" width="335" height="34" class="chip"/>
<text x="1162" y="2868" class="tiny bold" text-anchor="middle">Application encryption in addition to TLS</text>
<rect x="1400" y="2570" width="405" height="330" class="security"/>
<text x="1430" y="2610" class="box-title orange">D · Replay and transport</text>
<text x="1430" y="2645" class="small">• Redis SET-if-absent claims each nonce</text>
<text x="1430" y="2674" class="small">• Duplicate nonce is rejected</text>
<text x="1430" y="2703" class="small">• Timestamp freshness window enforced</text>
<text x="1430" y="2732" class="small">• HTTPS is mandatory outside local dev</text>
<text x="1430" y="2761" class="small">• Credentials and tokens are not logged</text>
<text x="1430" y="2790" class="small">• Private keys and assertions require</text>
<text x="1448" y="2816" class="small">owner-only storage and rotation controls</text>
<rect x="1430" y="2845" width="335" height="34" class="chip"/>
<text x="1597" y="2868" class="tiny bold" text-anchor="middle">Captured requests cannot be reused</text>
<!-- Cloud login internals -->
<text x="70" y="3017" class="section-title">5 · Cloud login internals and session compatibility</text>
<text x="70" y="3047" class="section-sub">Prepared SQL retrieves only the identity data needed to preserve the existing SessionValues contract.</text>
<rect x="70" y="3077" width="1095" height="500" class="card"/>
<text x="105" y="3120" class="box-title blue">Cloud identity processing</text>
<circle cx="125" cy="3170" r="14" class="step-circle"/><text x="125" y="3170" class="step-number">1</text>
<text x="155" y="3176" class="body">Decrypt payload and validate machine binding, timestamp and nonce.</text>
<circle cx="125" cy="3225" r="14" class="step-circle"/><text x="125" y="3225" class="step-number">2</text>
<text x="155" y="3231" class="body">Load active user by case-insensitive login ID from identity.app_user.</text>
<circle cx="125" cy="3280" r="14" class="step-circle"/><text x="125" y="3280" class="step-number">3</text>
<text x="155" y="3286" class="body">Verify password using the compatible legacy password verifier.</text>
<circle cx="125" cy="3335" r="14" class="step-circle"/><text x="125" y="3335" class="step-number">4</text>
<text x="155" y="3341" class="body">Load group, company and branch details.</text>
<circle cx="125" cy="3390" r="14" class="step-circle"/><text x="125" y="3390" class="step-number">5</text>
<text x="155" y="3396" class="body">Load visible permitted pages and exclude explicitly denied pages.</text>
<circle cx="125" cy="3445" r="14" class="step-circle"/><text x="125" y="3445" class="step-number">6</text>
<text x="155" y="3451" class="body">Insert cloud login audit row and return structured identity/menu response.</text>
<rect x="105" y="3490" width="1025" height="58" class="database"/>
<text x="617" y="3514" class="small bold" text-anchor="middle">Vert.x prepared queries keep values separate from SQL text</text>
<text x="617" y="3538" class="tiny muted" text-anchor="middle">Tables: identity.app_user · user_group · company · company_branch · permission · pages · denied_pages · user_loginhistory</text>
<rect x="1195" y="3077" width="655" height="500" class="card"/>
<text x="1230" y="3120" class="box-title green">Legacy compatibility mapping</text>
<text x="1230" y="3158" class="small bold">CloudSessionMapper populates:</text>
<text x="1250" y="3192" class="small">• User ID, login ID and display name</text>
<text x="1250" y="3222" class="small">• Group ID and group name</text>
<text x="1250" y="3252" class="small">• Company ID, name and code</text>
<text x="1250" y="3282" class="small">• Branch ID, name, code and location</text>
<text x="1250" y="3312" class="small">• Login timestamp in the legacy format</text>
<text x="1250" y="3342" class="small">• Menu HTML rendered from cloud menu records</text>
<rect x="1230" y="3380" width="585" height="116" class="onprem"/>
<text x="1522" y="3414" class="small bold" text-anchor="middle">No local password query or fallback remains</text>
<text x="1522" y="3443" class="small" text-anchor="middle">Existing JSP controllers keep reading SessionValues</text>
<text x="1522" y="3472" class="small" text-anchor="middle">Operational authorization continues without UI rewrites</text>
<text x="1230" y="3535" class="tiny muted">Logout currently invalidates the local HTTP session. A cloud logout/audit API is the future option if logout timestamps are required.</text>
<!-- Code navigation and failures -->
<text x="70" y="3647" class="section-title">6 · Code navigation</text>
<text x="70" y="3677" class="section-sub">Primary implementation points: SessionController → CloudAuthenticationGateway → OAuthMachineTokenProvider / LoginEnvelopeEncryptor → CloudLoginController → IdentityAuthenticationService → IdentityRepository → CloudSessionMapper.</text>
<!-- Footer strip overlays final area intentionally within document bounds -->
<rect x="50" y="3695" width="1820" height="1" fill="#9eb5c5"/>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

View File

@@ -0,0 +1,70 @@
# Cygnus cloud client
This module is the reusable on-premises gateway client for Cygnus cloud APIs.
It obtains a short-lived machine access token, encrypts login credentials with
the cloud login public key, and calls the cloud identity API over WebFlux.
## On-premises identity configuration
Cloud login is required by the on-premises application. Configure these as
environment variables or JVM system properties:
| Setting | Purpose |
| --- | --- |
| `CYGNUS_CLOUD_BASE_URL` | Cloud gateway/API base URL |
| `CYGNUS_TOKEN_URL` | OAuth 2.0 machine-token endpoint |
| `CYGNUS_CLIENT_ID` | Provisioned customer/client identifier |
| `CYGNUS_INSTALLATION_ID` | Unique on-premises installation identifier |
| `CYGNUS_CLIENT_ASSERTION` | Provisioned encrypted assertion or `file:/secure/path/assertion.jwt` |
| `CYGNUS_LOGIN_KEY_ID` | Cloud login encryption-key identifier |
| `CYGNUS_LOGIN_PUBLIC_KEY` | X.509 RSA public key location |
| `CYGNUS_CLOUD_REQUEST_TIMEOUT` | Request timeout, for example `PT10S` |
Do not store private keys, client assertions, passwords, or production URLs in
source control.
## Provision a machine assertion
The assertion is a signed JWT nested inside an RSA-OAEP-256/AES-256-GCM JWE.
It is valid for one year; the access token obtained with it is short-lived.
For local development, the repository setup script automates prerequisite
checks, the full Maven verification, directory creation, all three cloud key
pairs, the installation key pair, `config/clients.yml`, and the encrypted
machine assertion:
```bash
./scripts/setup-local-communication.sh
```
The script interactively asks for the customer identifier, installation
identifier, cloud URL, and whether to run the full verification. Customer and
installation identifiers cannot contain spaces; the customer identifier is
used for its directory and signing-key filenames. New customers are appended
to `config/clients.yml` without replacing existing customers.
It preserves existing private keys and assertions. Set
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
regenerated. The manual provisioning commands follow.
Generate the installation signing key:
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out client-signing-private.pem
openssl pkey -in client-signing-private.pem -pubout \
-out client-signing-public.pem
```
Generate the assertion after the cloud assertion-encryption public key has
been securely delivered:
```bash
mvn -pl cygnus-cloud-client exec:java \
-Dexec.mainClass=com.cygnus.client.provisioning.MachineAssertionGenerator \
-Dexec.args="customer-a site-01 https://cloud.example.com/oauth2/token \
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
```
Copy only `client-signing-public.pem` into that customer's cloud-side client
configuration. Keep the private key and generated assertion on the on-premises
server with owner-only filesystem permissions.

View File

@@ -0,0 +1,57 @@
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cygnus</groupId>
<artifactId>cygnus-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cygnus-cloud-client</artifactId>
<packaging>jar</packaging>
<name>Cygnus Cloud Client</name>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-http</artifactId>
<version>${reactor.netty.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,25 @@
package com.cygnus.client;
import com.cygnus.client.security.LoginEnvelopeEncryptor;
import com.cygnus.client.security.MachineTokenProvider;
import com.cygnus.client.security.OAuthMachineTokenProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import org.springframework.web.reactive.function.client.WebClient;
public final class CloudClientFactory {
private CloudClientFactory() {
}
public static CloudIdentityClient create(
CloudClientProperties properties, ObjectMapper objectMapper, Clock clock) {
WebClient webClient = WebClient.builder().build();
MachineTokenProvider tokenProvider =
new OAuthMachineTokenProvider(webClient, properties, clock);
LoginEnvelopeEncryptor encryptor =
new LoginEnvelopeEncryptor(properties, objectMapper);
return new CloudIdentityClient(
webClient, tokenProvider, encryptor, properties, clock);
}
}

View File

@@ -0,0 +1,71 @@
package com.cygnus.client;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public record CloudClientProperties(
URI baseUri,
URI tokenUri,
String clientId,
String installationId,
String clientAssertion,
String loginKeyId,
String loginPublicKeyLocation,
Duration requestTimeout,
Duration tokenRefreshSkew) {
public static CloudClientProperties fromSystem() {
CloudClientProperties properties = new CloudClientProperties(
URI.create(value("CYGNUS_CLOUD_BASE_URL", "http://localhost:8090")),
URI.create(value(
"CYGNUS_TOKEN_URL",
"http://localhost:8090/oauth2/token")),
value("CYGNUS_CLIENT_ID", ""),
value("CYGNUS_INSTALLATION_ID", ""),
value("CYGNUS_CLIENT_ASSERTION", ""),
value("CYGNUS_LOGIN_KEY_ID", "cygnus-login-2026-01"),
value(
"CYGNUS_LOGIN_PUBLIC_KEY",
"file:./config/keys/login-public.pem"),
Duration.parse(value("CYGNUS_CLOUD_REQUEST_TIMEOUT", "PT10S")),
Duration.parse(value("CYGNUS_TOKEN_REFRESH_SKEW", "PT30S")));
properties.validate();
return properties;
}
public void validate() {
List<String> missing = new ArrayList<>();
require(clientId, "CYGNUS_CLIENT_ID", missing);
require(installationId, "CYGNUS_INSTALLATION_ID", missing);
require(clientAssertion, "CYGNUS_CLIENT_ASSERTION", missing);
require(loginKeyId, "CYGNUS_LOGIN_KEY_ID", missing);
require(loginPublicKeyLocation, "CYGNUS_LOGIN_PUBLIC_KEY", missing);
if (!missing.isEmpty()) {
throw new IllegalStateException(
"Cloud identity requires these settings: "
+ String.join(", ", missing));
}
if (requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalStateException(
"CYGNUS_CLOUD_REQUEST_TIMEOUT must be greater than zero");
}
}
private static void require(String value, String name, List<String> missing) {
if (value == null || value.isBlank()) {
missing.add(name);
}
}
private static String value(String name, String defaultValue) {
String systemValue = System.getProperty(name);
if (systemValue != null) {
return systemValue;
}
String environmentValue = System.getenv(name);
return environmentValue == null ? defaultValue : environmentValue;
}
}

View File

@@ -0,0 +1,53 @@
package com.cygnus.client;
import com.cygnus.client.model.CloudIdentitySession;
import com.cygnus.client.model.LoginPayload;
import com.cygnus.client.security.LoginEnvelopeEncryptor;
import com.cygnus.client.security.MachineTokenProvider;
import java.time.Clock;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
public class CloudIdentityClient {
private final WebClient webClient;
private final MachineTokenProvider tokenProvider;
private final LoginEnvelopeEncryptor encryptor;
private final CloudClientProperties properties;
private final Clock clock;
public CloudIdentityClient(
WebClient webClient,
MachineTokenProvider tokenProvider,
LoginEnvelopeEncryptor encryptor,
CloudClientProperties properties,
Clock clock) {
this.webClient = webClient;
this.tokenProvider = tokenProvider;
this.encryptor = encryptor;
this.properties = properties;
this.clock = clock;
}
public Mono<CloudIdentitySession> authenticate(String loginId, String password) {
LoginPayload payload = new LoginPayload(
loginId,
password,
properties.clientId(),
properties.installationId(),
UUID.randomUUID().toString(),
clock.instant());
return tokenProvider.accessToken()
.flatMap(token -> webClient.post()
.uri(properties.baseUri().resolve("/api/v1/identity/login"))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(encryptor.encrypt(payload))
.retrieve()
.bodyToMono(CloudIdentitySession.class))
.timeout(properties.requestTimeout());
}
}

View File

@@ -0,0 +1,21 @@
package com.cygnus.client.model;
import java.time.Instant;
import java.util.List;
public record CloudIdentitySession(
short userId,
String loginId,
String displayName,
short groupId,
String groupName,
short branchId,
String branchName,
String branchCode,
String branchLocation,
short companyId,
String companyName,
String companyCode,
Instant loginTime,
List<CloudMenuItem> menu) {
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.client.model;
public record CloudMenuItem(
short pageId,
String label,
String targetUrl,
short parentPage,
short pageOrder,
String permission,
String targetWindow,
String requestValue) {
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.client.model;
public record EncryptedLoginRequest(
String keyId,
String encryptedKey,
String initializationVector,
String encryptedPayload) {
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.client.model;
import java.time.Instant;
public record LoginPayload(
String loginId,
String password,
String clientId,
String installationId,
String nonce,
Instant issuedAt) {
}

View File

@@ -0,0 +1,99 @@
package com.cygnus.client.provisioning;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSAEncrypter;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import java.util.Set;
public final class MachineAssertionGenerator {
private MachineAssertionGenerator() {
}
public static void main(String[] args) throws Exception {
if (args.length != 6) {
throw new IllegalArgumentException(
"Expected: <client-id> <installation-id> <token-audience> "
+ "<client-signing-private.pem> "
+ "<cloud-assertion-public.pem> <output-file>");
}
Instant issuedAt = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(args[0])
.subject(args[0])
.audience(args[2])
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(issuedAt.plus(365, ChronoUnit.DAYS)))
.claim("installation_id", args[1])
.build();
SignedJWT signed = new SignedJWT(
new JWSHeader(JWSAlgorithm.RS256), claims);
signed.sign(new RSASSASigner(privateKey(Path.of(args[3]))));
JWEObject encrypted = new JWEObject(
new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM),
new Payload(signed.serialize()));
encrypted.encrypt(new RSAEncrypter(publicKey(Path.of(args[4]))));
Path output = Path.of(args[5]).toAbsolutePath();
Files.writeString(
output,
encrypted.serialize(),
StandardCharsets.US_ASCII,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
try {
Files.setPosixFilePermissions(
output,
Set.of(
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
} catch (UnsupportedOperationException ignored) {
// Non-POSIX platforms must secure this file through their native ACL.
}
System.out.println("Machine assertion written to " + output);
}
private static RSAPrivateKey privateKey(Path path) throws Exception {
String encoded = pem(path, "PRIVATE KEY");
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
}
private static RSAPublicKey publicKey(Path path) throws Exception {
String encoded = pem(path, "PUBLIC KEY");
return (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
}
private static String pem(Path path, String type) throws Exception {
return Files.readString(path, StandardCharsets.US_ASCII)
.replace("-----BEGIN " + type + "-----", "")
.replace("-----END " + type + "-----", "")
.replaceAll("\\s", "");
}
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.client.security;
public class CloudClientException extends RuntimeException {
public CloudClientException(String message) {
super(message);
}
public CloudClientException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,120 @@
package com.cygnus.client.security;
import com.cygnus.client.CloudClientProperties;
import com.cygnus.client.model.EncryptedLoginRequest;
import com.cygnus.client.model.LoginPayload;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
public class LoginEnvelopeEncryptor {
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT);
private final CloudClientProperties properties;
private final ObjectMapper objectMapper;
private final SecureRandom secureRandom;
private volatile PublicKey publicKey;
public LoginEnvelopeEncryptor(
CloudClientProperties properties, ObjectMapper objectMapper) {
this(properties, objectMapper, new SecureRandom());
}
LoginEnvelopeEncryptor(
CloudClientProperties properties,
ObjectMapper objectMapper,
SecureRandom secureRandom) {
this.properties = properties;
this.objectMapper = objectMapper;
this.secureRandom = secureRandom;
}
public EncryptedLoginRequest encrypt(LoginPayload payload) {
try {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256, secureRandom);
SecretKey aesKey = generator.generateKey();
byte[] initializationVector = new byte[12];
secureRandom.nextBytes(initializationVector);
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
payloadCipher.init(
Cipher.ENCRYPT_MODE,
aesKey,
new GCMParameterSpec(128, initializationVector));
payloadCipher.updateAAD(
properties.loginKeyId().getBytes(StandardCharsets.UTF_8));
byte[] encryptedPayload =
payloadCipher.doFinal(objectMapper.writeValueAsBytes(payload));
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
keyCipher.init(Cipher.ENCRYPT_MODE, publicKey(), OAEP_SHA_256);
byte[] encryptedKey = keyCipher.doFinal(aesKey.getEncoded());
Base64.Encoder base64 = Base64.getEncoder();
return new EncryptedLoginRequest(
properties.loginKeyId(),
base64.encodeToString(encryptedKey),
base64.encodeToString(initializationVector),
base64.encodeToString(encryptedPayload));
} catch (Exception exception) {
throw new CloudClientException("Unable to encrypt cloud login request", exception);
}
}
private PublicKey publicKey() throws Exception {
PublicKey loaded = publicKey;
if (loaded != null) {
return loaded;
}
synchronized (this) {
if (publicKey == null) {
String pem;
String location = properties.loginPublicKeyLocation();
if (location.startsWith("classpath:")) {
String resourceName = location.substring("classpath:".length());
try (InputStream stream = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(resourceName)) {
if (stream == null) {
throw new IllegalArgumentException(
"Public key resource not found: " + resourceName);
}
pem = new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
}
} else {
String fileName =
location.startsWith("file:") ? location.substring(5) : location;
pem = Files.readString(Path.of(fileName), StandardCharsets.US_ASCII);
}
String encoded = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
}
return publicKey;
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.client.security;
import reactor.core.publisher.Mono;
public interface MachineTokenProvider {
Mono<String> accessToken();
}

View File

@@ -0,0 +1,102 @@
package com.cygnus.client.security;
import com.cygnus.client.CloudClientProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Clock;
import java.time.Instant;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
public class OAuthMachineTokenProvider implements MachineTokenProvider {
private static final String ASSERTION_TYPE =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
private final WebClient webClient;
private final CloudClientProperties properties;
private final Clock clock;
private final AtomicReference<CachedToken> cachedToken = new AtomicReference<>();
private volatile Mono<String> tokenRequest;
public OAuthMachineTokenProvider(
WebClient webClient, CloudClientProperties properties, Clock clock) {
this.webClient = webClient;
this.properties = properties;
this.clock = clock;
}
@Override
public Mono<String> accessToken() {
CachedToken current = cachedToken.get();
if (current != null
&& clock.instant().isBefore(
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
return Mono.just(current.value());
}
return refreshToken();
}
private synchronized Mono<String> refreshToken() {
CachedToken current = cachedToken.get();
if (current != null
&& clock.instant().isBefore(
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
return Mono.just(current.value());
}
if (tokenRequest == null) {
tokenRequest = webClient.post()
.uri(properties.tokenUri())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData("grant_type", "client_credentials")
.with("client_id", properties.clientId())
.with("client_assertion_type", ASSERTION_TYPE)
.with("client_assertion", clientAssertion())
.with("scope", "identity.login"))
.retrieve()
.bodyToMono(TokenResponse.class)
.map(response -> {
cachedToken.set(new CachedToken(
response.accessToken(),
clock.instant().plusSeconds(response.expiresIn())));
return response.accessToken();
})
.doFinally(signal -> clearInFlight())
.cache();
}
return tokenRequest;
}
private synchronized void clearInFlight() {
tokenRequest = null;
}
private String clientAssertion() {
String configured = properties.clientAssertion();
if (!configured.startsWith("file:")) {
return configured;
}
try {
return Files.readString(
Path.of(configured.substring("file:".length())),
StandardCharsets.US_ASCII)
.trim();
} catch (Exception exception) {
throw new IllegalStateException("Unable to read machine client assertion", exception);
}
}
private record CachedToken(String value, Instant expiresAt) {
}
private record TokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") long expiresIn,
@JsonProperty("token_type") String tokenType) {
}
}

View File

@@ -0,0 +1,42 @@
package com.cygnus.client;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.URI;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class CloudClientPropertiesTest {
@Test
void configurationRequiresMachineCredentials() {
CloudClientProperties properties = properties("", "", "");
assertThrows(IllegalStateException.class, properties::validate);
}
@Test
void configurationAcceptsCompleteMachineIdentity() {
CloudClientProperties properties =
properties("customer-a", "site-01", "signed-assertion");
assertDoesNotThrow(properties::validate);
}
private CloudClientProperties properties(
String clientId,
String installationId,
String assertion) {
return new CloudClientProperties(
URI.create("https://cloud.example.test"),
URI.create("https://identity.example.test/oauth2/token"),
clientId,
installationId,
assertion,
"login-key-01",
"file:./login-public.pem",
Duration.ofSeconds(10),
Duration.ofSeconds(30));
}
}

View File

@@ -0,0 +1,3 @@
artifactId=cygnus-cloud-client
groupId=com.cygnus
version=1.0.0-SNAPSHOT

View File

@@ -0,0 +1,14 @@
com/cygnus/client/security/MachineTokenProvider.class
com/cygnus/client/security/OAuthMachineTokenProvider$TokenResponse.class
com/cygnus/client/provisioning/MachineAssertionGenerator.class
com/cygnus/client/CloudClientProperties.class
com/cygnus/client/security/LoginEnvelopeEncryptor.class
com/cygnus/client/model/LoginPayload.class
com/cygnus/client/model/CloudIdentitySession.class
com/cygnus/client/security/OAuthMachineTokenProvider.class
com/cygnus/client/security/OAuthMachineTokenProvider$CachedToken.class
com/cygnus/client/CloudClientFactory.class
com/cygnus/client/model/CloudMenuItem.class
com/cygnus/client/model/EncryptedLoginRequest.class
com/cygnus/client/security/CloudClientException.class
com/cygnus/client/CloudIdentityClient.class

View File

@@ -0,0 +1,12 @@
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientFactory.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientProperties.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudIdentityClient.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudIdentitySession.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudMenuItem.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/EncryptedLoginRequest.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/LoginPayload.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/provisioning/MachineAssertionGenerator.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/CloudClientException.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/LoginEnvelopeEncryptor.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/MachineTokenProvider.java
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/OAuthMachineTokenProvider.java

View File

@@ -0,0 +1 @@
com/cygnus/client/CloudClientPropertiesTest.class

View File

@@ -0,0 +1 @@
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report.xsd" version="3.0.2" name="com.cygnus.client.CloudClientPropertiesTest" time="0.016" tests="2" errors="0" skipped="0" failures="0">
<properties>
<property name="java.specification.version" value="21"/>
<property name="sun.jnu.encoding" value="UTF-8"/>
<property name="java.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
<property name="java.vm.vendor" value="Microsoft"/>
<property name="sun.arch.data.model" value="64"/>
<property name="java.vendor.url" value="https://www.microsoft.com"/>
<property name="os.name" value="Mac OS X"/>
<property name="java.vm.specification.version" value="21"/>
<property name="sun.java.launcher" value="SUN_STANDARD"/>
<property name="user.country" value="US"/>
<property name="sun.boot.library.path" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home/lib"/>
<property name="sun.java.command" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar /Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire 2026-07-24T21-55-20_356-jvmRun1 surefire-20260724215520397_1tmp surefire_0-20260724215520397_2tmp"/>
<property name="http.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="jdk.debug" value="release"/>
<property name="test" value="CloudClientPropertiesTest"/>
<property name="surefire.test.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
<property name="sun.cpu.endian" value="little"/>
<property name="user.home" value="/Users/maddy"/>
<property name="user.language" value="en"/>
<property name="java.specification.vendor" value="Oracle Corporation"/>
<property name="java.version.date" value="2025-07-15"/>
<property name="java.home" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"/>
<property name="file.separator" value="/"/>
<property name="basedir" value="/Users/maddy/Projects/matrix/cygnus-cloud-client"/>
<property name="java.vm.compressedOopsMode" value="Zero based"/>
<property name="line.separator" value="&#10;"/>
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
<property name="java.specification.name" value="Java Platform API Specification"/>
<property name="apple.awt.application.name" value="ForkedBooter"/>
<property name="surefire.real.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar"/>
<property name="sun.management.compiler" value="HotSpot 64-Bit Tiered Compilers"/>
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="java.runtime.version" value="21.0.8+9-LTS"/>
<property name="user.name" value="maddy"/>
<property name="stdout.encoding" value="UTF-8"/>
<property name="path.separator" value=":"/>
<property name="os.version" value="26.5.2"/>
<property name="java.runtime.name" value="OpenJDK Runtime Environment"/>
<property name="file.encoding" value="UTF-8"/>
<property name="java.vm.name" value="OpenJDK 64-Bit Server VM"/>
<property name="java.vendor.version" value="Microsoft-11933201"/>
<property name="localRepository" value="/Users/maddy/.m2/repository"/>
<property name="java.vendor.url.bug" value="https://github.com/microsoft/openjdk/issues"/>
<property name="java.io.tmpdir" value="/var/folders/1l/36214rdn79755j30lcnmgsqh0000gn/T/"/>
<property name="java.version" value="21.0.8"/>
<property name="user.dir" value="/Users/maddy/Projects/matrix/cygnus-cloud-client"/>
<property name="os.arch" value="aarch64"/>
<property name="java.vm.specification.name" value="Java Virtual Machine Specification"/>
<property name="native.encoding" value="UTF-8"/>
<property name="java.library.path" value="/Users/maddy/Library/Java/Extensions:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java:."/>
<property name="java.vm.info" value="mixed mode, sharing"/>
<property name="stderr.encoding" value="UTF-8"/>
<property name="java.vendor" value="Microsoft"/>
<property name="java.vm.version" value="21.0.8+9-LTS"/>
<property name="sun.io.unicode.encoding" value="UnicodeBig"/>
<property name="socksNonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="java.class.version" value="65.0"/>
</properties>
<testcase name="configurationAcceptsCompleteMachineIdentity" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.007"/>
<testcase name="configurationRequiresMachineCredentials" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.001"/>
</testsuite>

View File

@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.cygnus.client.CloudClientPropertiesTest
-------------------------------------------------------------------------------
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 s -- in com.cygnus.client.CloudClientPropertiesTest

View File

@@ -0,0 +1,98 @@
# Cygnus cloud service
## Identity login API
`POST /api/v1/identity/login` requires a valid machine JWT with the
`identity.login` scope. The JWT must carry `client_id` and `installation_id`;
both must equal the values inside the encrypted payload.
The request uses a hybrid encrypted envelope:
```json
{
"keyId": "cygnus-login-2026-01",
"encryptedKey": "base64 RSA-OAEP-SHA256 encrypted AES key",
"initializationVector": "base64 12-byte AES-GCM IV",
"encryptedPayload": "base64 AES-GCM ciphertext and tag"
}
```
The AES-GCM additional authenticated data is the UTF-8 `keyId`. The decrypted
JSON is:
```json
{
"loginId": "user",
"password": "password",
"clientId": "client-id-from-jwt",
"installationId": "installation-id-from-jwt",
"nonce": "unique-random-value",
"issuedAt": "2026-07-23T06:30:00Z"
}
```
Configure the PKCS#8 RSA private key with
`CYGNUS_LOGIN_PRIVATE_KEY=file:/secure/path/login-private.pem`. Keep this key
outside the source tree and container image. The corresponding public key is
distributed to the on-prem gateway.
The database bootstrap is
`src/main/resources/db/identity/001_identity_login_schema.sql`. It is
transactional and idempotent; it copies login/menu data from `matrix.public`
to `matrix.identity`. It is intended for initial migration and controlled
development refreshes. Do not run it after `identity` becomes the production
system of record because its upserts intentionally refresh rows from `public`.
## Machine token endpoint
`POST /oauth2/token` implements the client-credentials flow used by the
on-premises gateway. The client assertion must be:
- an inner RS256 JWT signed with the installation private key;
- encrypted as RSA-OAEP-256 plus AES-256-GCM using the cloud assertion key;
- bound to the configured client ID, installation ID, and token audience;
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
The endpoint returns a short-lived RS256 access token carrying `client_id`,
`installation_id`, and the approved scope. The identity endpoint requires the
`identity.login` scope and verifies the same machine binding in the encrypted
login payload.
Generate separate cloud key pairs:
```bash
mkdir -p config/keys
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out config/keys/assertion-decryption-private.pem
openssl pkey -in config/keys/assertion-decryption-private.pem -pubout \
-out config/keys/assertion-decryption-public.pem
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out config/keys/access-token-private.pem
openssl pkey -in config/keys/access-token-private.pem -pubout \
-out config/keys/access-token-public.pem
chmod 600 config/keys/*private.pem
```
Configure clients in an external Spring YAML file rather than the packaged
`application.yml`:
```yaml
cygnus:
security:
enabled: true
issuer-uri: https://cloud.example.com
audience: cygnus-cloud-api
token-audience: https://cloud.example.com/oauth2/token
clients:
customer-a:
enabled: true
installation-id: site-01
assertion-public-key: file:/secure/clients/customer-a/public.pem
scopes:
- identity.login
```
Start with that protected file using
`--spring.config.additional-location=file:/secure/cygnus/clients.yml`.
Never place cloud private keys, customer assertions, or installation private
keys in the repository or container image.

View File

@@ -0,0 +1,84 @@
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cygnus</groupId>
<artifactId>cygnus-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cygnus-cloud-service</artifactId>
<packaging>jar</packaging>
<name>Cygnus Cloud Service</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</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-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-pg-client</artifactId>
<version>${vertx.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,14 @@
package com.cygnus.cloud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class CygnusCloudServiceApplication {
public static void main(String[] args) {
SpringApplication.run(CygnusCloudServiceApplication.class, args);
}
}

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.cache;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.cache")
public record CacheProperties(String keyPrefix, Duration defaultTtl) {
}

View File

@@ -0,0 +1,44 @@
package com.cygnus.cloud.cache;
import java.time.Duration;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class ReactiveCacheService {
private final ReactiveStringRedisTemplate redis;
private final CacheProperties properties;
public ReactiveCacheService(ReactiveStringRedisTemplate redis, CacheProperties properties) {
this.redis = redis;
this.properties = properties;
}
public Mono<String> get(String namespace, String key) {
return redis.opsForValue().get(cacheKey(namespace, key));
}
public Mono<Boolean> put(String namespace, String key, String value) {
return put(namespace, key, value, properties.defaultTtl());
}
public Mono<Boolean> put(String namespace, String key, String value, Duration ttl) {
return redis.opsForValue().set(cacheKey(namespace, key), value, ttl);
}
public Mono<Boolean> putIfAbsent(String namespace, String key, String value, Duration ttl) {
return redis.opsForValue().setIfAbsent(cacheKey(namespace, key), value, ttl);
}
public Mono<Boolean> evict(String namespace, String key) {
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
}
private String cacheKey(String namespace, String key) {
return properties.keyPrefix() + ':' + namespace + ':' + key;
}
}

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.database;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.database")
public record DatabaseProperties(
String host,
int port,
String database,
String username,
String password,
boolean ssl,
Duration connectTimeout,
int poolSize,
int poolWaitQueueSize) {
}

View File

@@ -0,0 +1,32 @@
package com.cygnus.cloud.database;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
import io.vertx.sqlclient.Tuple;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
/**
* Reactor boundary around the Vert.x PostgreSQL pool. Feature repositories
* should use parameterized SQL and pass values through {@link Tuple}.
*/
@Service
public class ReactiveDatabaseClient {
private final Pool pool;
public ReactiveDatabaseClient(Pool pool) {
this.pool = pool;
}
public Mono<RowSet<Row>> query(String sql) {
return Mono.fromCompletionStage(() -> pool.query(sql).execute().toCompletionStage());
}
public Mono<RowSet<Row>> preparedQuery(String sql, Tuple parameters) {
return Mono.fromCompletionStage(
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
}
}

View File

@@ -0,0 +1,44 @@
package com.cygnus.cloud.database;
import io.vertx.core.Vertx;
import io.vertx.pgclient.PgConnectOptions;
import io.vertx.pgclient.PgBuilder;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class VertxDatabaseConfiguration {
@Bean(destroyMethod = "close")
Vertx vertx() {
return Vertx.vertx();
}
@Bean(destroyMethod = "close")
Pool postgresPool(Vertx vertx, DatabaseProperties properties) {
PgConnectOptions connection = new PgConnectOptions()
.setHost(properties.host())
.setPort(properties.port())
.setDatabase(properties.database())
.setUser(properties.username())
.setPassword(properties.password())
.setSslMode(properties.ssl()
? io.vertx.pgclient.SslMode.REQUIRE
: io.vertx.pgclient.SslMode.DISABLE);
PoolOptions pool = new PoolOptions()
.setMaxSize(properties.poolSize())
.setMaxWaitQueueSize(properties.poolWaitQueueSize())
.setConnectionTimeout(Math.toIntExact(properties.connectTimeout().toMillis()))
.setConnectionTimeoutUnit(TimeUnit.MILLISECONDS);
return PgBuilder.pool()
.using(vertx)
.connectingTo(connection)
.with(pool)
.build();
}
}

View File

@@ -0,0 +1,106 @@
package com.cygnus.cloud.identity.api;
import com.cygnus.cloud.identity.model.AuthenticatedIdentity;
import com.cygnus.cloud.identity.service.AuthenticationException;
import com.cygnus.cloud.identity.service.IdentityAuthenticationService;
import com.cygnus.cloud.identity.service.LoginRequestReplayService;
import jakarta.validation.Valid;
import java.time.Clock;
import java.time.Duration;
import org.springframework.util.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/identity")
public class CloudLoginController {
private final RsaLoginPayloadDecryptor decryptor;
private final IdentityAuthenticationService authenticationService;
private final LoginRequestReplayService replayService;
private final LoginEncryptionProperties encryptionProperties;
private final Clock clock;
public CloudLoginController(
RsaLoginPayloadDecryptor decryptor,
IdentityAuthenticationService authenticationService,
LoginRequestReplayService replayService,
LoginEncryptionProperties encryptionProperties,
Clock clock) {
this.decryptor = decryptor;
this.authenticationService = authenticationService;
this.replayService = replayService;
this.encryptionProperties = encryptionProperties;
this.clock = clock;
}
@PostMapping("/login")
public Mono<AuthenticatedIdentity> login(
@AuthenticationPrincipal Jwt machineJwt,
@Valid @RequestBody EncryptedLoginRequest request,
ServerHttpRequest serverRequest) {
if (machineJwt == null) {
return Mono.error(new AuthenticationException("Machine authentication required"));
}
LoginPayload payload = decryptor.decrypt(request);
validatePayload(payload);
validateMachineBinding(machineJwt, payload);
validateFreshness(payload);
return replayService
.claim(
payload.installationId(),
payload.nonce(),
encryptionProperties.payloadTtl())
.flatMap(claimed -> {
if (!claimed) {
return Mono.error(new AuthenticationException("Login request replayed"));
}
return authenticationService.authenticate(
payload.loginId(),
payload.password(),
remoteAddress(serverRequest));
});
}
private void validatePayload(LoginPayload payload) {
if (payload == null
|| !StringUtils.hasText(payload.loginId())
|| !StringUtils.hasText(payload.password())
|| !StringUtils.hasText(payload.clientId())
|| !StringUtils.hasText(payload.installationId())
|| !StringUtils.hasText(payload.nonce())
|| payload.issuedAt() == null) {
throw new AuthenticationException("Invalid login payload");
}
}
private void validateMachineBinding(Jwt jwt, LoginPayload payload) {
String authenticatedClient = jwt.getClaimAsString("client_id");
String authenticatedInstallation = jwt.getClaimAsString("installation_id");
if (!payload.clientId().equals(authenticatedClient)
|| !payload.installationId().equals(authenticatedInstallation)) {
throw new AuthenticationException("Machine identity mismatch");
}
}
private void validateFreshness(LoginPayload payload) {
Duration age = Duration.between(payload.issuedAt(), clock.instant()).abs();
if (age.compareTo(encryptionProperties.payloadTtl()) > 0) {
throw new AuthenticationException("Login request expired");
}
}
private String remoteAddress(ServerHttpRequest request) {
return request.getRemoteAddress() == null
? null
: request.getRemoteAddress().getAddress().getHostAddress();
}
}

View File

@@ -0,0 +1,10 @@
package com.cygnus.cloud.identity.api;
import jakarta.validation.constraints.NotBlank;
public record EncryptedLoginRequest(
@NotBlank String keyId,
@NotBlank String encryptedKey,
@NotBlank String initializationVector,
@NotBlank String encryptedPayload) {
}

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.identity.api;
import com.cygnus.cloud.identity.service.AuthenticationException;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class IdentityErrorHandler {
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
Map<String, String> authenticationFailure() {
return Map.of("code", "AUTHENTICATION_FAILED", "message", "Authentication failed");
}
}

View File

@@ -0,0 +1,11 @@
package com.cygnus.cloud.identity.api;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.login-encryption")
public record LoginEncryptionProperties(
String keyId,
String privateKeyLocation,
Duration payloadTtl) {
}

View File

@@ -0,0 +1,14 @@
package com.cygnus.cloud.identity.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
public record LoginPayload(
@NotBlank String loginId,
@NotBlank String password,
@NotBlank String clientId,
@NotBlank String installationId,
@NotBlank String nonce,
@NotNull Instant issuedAt) {
}

View File

@@ -0,0 +1,92 @@
package com.cygnus.cloud.identity.api;
import com.cygnus.cloud.identity.service.AuthenticationException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;
@Component
public class RsaLoginPayloadDecryptor {
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT);
private final LoginEncryptionProperties properties;
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
private volatile PrivateKey privateKey;
public RsaLoginPayloadDecryptor(
LoginEncryptionProperties properties,
ObjectMapper objectMapper,
ResourceLoader resourceLoader) {
this.properties = properties;
this.objectMapper = objectMapper;
this.resourceLoader = resourceLoader;
}
public LoginPayload decrypt(EncryptedLoginRequest request) {
if (!properties.keyId().equals(request.keyId())) {
throw new AuthenticationException("Unsupported encryption key");
}
try {
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
keyCipher.init(Cipher.DECRYPT_MODE, privateKey(), OAEP_SHA_256);
byte[] aesKey =
keyCipher.doFinal(Base64.getDecoder().decode(request.encryptedKey()));
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
payloadCipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(aesKey, "AES"),
new GCMParameterSpec(
128,
Base64.getDecoder().decode(request.initializationVector())));
payloadCipher.updateAAD(request.keyId().getBytes(StandardCharsets.UTF_8));
byte[] plaintext = payloadCipher.doFinal(
Base64.getDecoder().decode(request.encryptedPayload()));
return objectMapper.readValue(plaintext, LoginPayload.class);
} catch (AuthenticationException exception) {
throw exception;
} catch (Exception exception) {
throw new AuthenticationException("Invalid encrypted login request");
}
}
private PrivateKey privateKey() throws Exception {
PrivateKey loaded = privateKey;
if (loaded != null) {
return loaded;
}
synchronized (this) {
if (privateKey == null) {
Resource resource =
resourceLoader.getResource(properties.privateKeyLocation());
String pem = resource.getContentAsString(StandardCharsets.US_ASCII);
String encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(encoded);
privateKey = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}
return privateKey;
}
}
}

View File

@@ -0,0 +1,21 @@
package com.cygnus.cloud.identity.model;
import java.time.Instant;
import java.util.List;
public record AuthenticatedIdentity(
short userId,
String loginId,
String displayName,
short groupId,
String groupName,
short branchId,
String branchName,
String branchCode,
String branchLocation,
short companyId,
String companyName,
String companyCode,
Instant loginTime,
List<MenuItem> menu) {
}

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.identity.model;
public record IdentityUser(
short userId,
String loginId,
String displayName,
String legacyPassword,
short groupId,
String groupName,
short branchId,
String branchName,
String branchCode,
String branchLocation,
short companyId,
String companyName,
String companyCode,
boolean active) {
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.cloud.identity.model;
public record MenuItem(
short pageId,
String label,
String targetUrl,
short parentPage,
short pageOrder,
String permission,
String targetWindow,
String requestValue) {
}

View File

@@ -0,0 +1,82 @@
package com.cygnus.cloud.identity.repository;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.model.MenuItem;
import io.vertx.sqlclient.Tuple;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Repository
public class IdentityRepository {
private static final String FIND_USER = """
SELECT u.user_id, u.loginid, u.displayname, u.loginpassword, u.group_id,
g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city,
u.company_id, c.companyname, c.companycode, u.isactive
FROM identity.app_user u
JOIN identity.user_group g ON g.group_id = u.group_id
JOIN identity.company c ON c.company_id = u.company_id
JOIN identity.company_branch b
ON b.branch_id = u.branch_id AND b.company_id = u.company_id
WHERE upper(u.loginid) = upper($1)
""";
private static final String FIND_MENU = """
SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder,
permissions.permission, p.targetwindow, permissions.requestval
FROM identity.permission permissions
JOIN identity.pages p ON p.page_id = permissions.page_id
WHERE permissions.group_id = $1
AND p.isvisible = 1
AND permissions.permission <> '000'
AND NOT EXISTS (
SELECT 1
FROM identity.denied_pages denied
WHERE denied.user_id = $2
AND denied.page_id = permissions.page_id
AND denied.isdenied = 1
)
ORDER BY p.parentpage, p.pageorder DESC, p.page_id
""";
private static final String RECORD_LOGIN = """
INSERT INTO identity.user_loginhistory
(loginid, logintime, ipaddr, user_id)
VALUES ($1, $2, $3, $4)
RETURNING uid
""";
private final ReactiveDatabaseClient database;
private final IdentityRowMapper mapper;
public IdentityRepository(ReactiveDatabaseClient database, IdentityRowMapper mapper) {
this.database = database;
this.mapper = mapper;
}
public Flux<IdentityUser> findUsersByLoginId(String loginId) {
return database.preparedQuery(FIND_USER, Tuple.of(loginId))
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
}
public Mono<List<MenuItem>> findMenu(short groupId, short userId) {
return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId))
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
.collectList();
}
public Mono<Long> recordLogin(
String loginId, Instant loginTime, String remoteAddress, short userId) {
LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
return database.preparedQuery(
RECORD_LOGIN,
Tuple.of(loginId, databaseTime, remoteAddress, userId))
.map(rows -> rows.iterator().next().getLong("uid"));
}
}

View File

@@ -0,0 +1,40 @@
package com.cygnus.cloud.identity.repository;
import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.model.MenuItem;
import io.vertx.sqlclient.Row;
import org.springframework.stereotype.Component;
@Component
public class IdentityRowMapper {
IdentityUser user(Row row) {
return new IdentityUser(
row.getShort("user_id"),
row.getString("loginid"),
row.getString("displayname"),
row.getString("loginpassword"),
row.getShort("group_id"),
row.getString("group_name"),
row.getShort("branch_id"),
row.getString("branchname"),
row.getString("branchcode"),
row.getString("city"),
row.getShort("company_id"),
row.getString("companyname"),
row.getString("companycode"),
row.getShort("isactive") == 1);
}
MenuItem menuItem(Row row) {
return new MenuItem(
row.getShort("page_id"),
row.getString("menulabel"),
row.getString("targeturl"),
row.getShort("parentpage"),
row.getShort("pageorder"),
row.getString("permission").trim(),
row.getString("targetwindow"),
row.getString("requestval"));
}
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.identity.service;
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,79 @@
package com.cygnus.cloud.identity.service;
import com.cygnus.cloud.identity.model.AuthenticatedIdentity;
import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.repository.IdentityRepository;
import java.time.Clock;
import java.time.Instant;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class IdentityAuthenticationService {
private final IdentityRepository repository;
private final LegacyPasswordVerifier passwordVerifier;
private final Clock clock;
public IdentityAuthenticationService(
IdentityRepository repository,
LegacyPasswordVerifier passwordVerifier,
Clock clock) {
this.repository = repository;
this.passwordVerifier = passwordVerifier;
this.clock = clock;
}
public Mono<AuthenticatedIdentity> authenticate(
String loginId, String password, String remoteAddress) {
return repository.findUsersByLoginId(loginId)
.collectList()
.flatMap(users -> {
if (users.isEmpty()) {
return Mono.error(new AuthenticationException("Invalid credentials"));
}
java.util.List<IdentityUser> exactMatches = users.stream()
.filter(user -> user.loginId().equals(loginId))
.toList();
IdentityUser user;
if (exactMatches.size() == 1) {
user = exactMatches.getFirst();
} else if (users.size() == 1) {
user = users.getFirst();
} else {
return Mono.error(
new AuthenticationException("Ambiguous login identity"));
}
if (!user.active()
|| !passwordVerifier.matches(password, user.legacyPassword())) {
return Mono.error(new AuthenticationException("Invalid credentials"));
}
Instant loginTime = clock.instant();
return repository.findMenu(user.groupId(), user.userId())
.flatMap(menu -> repository.recordLogin(
user.loginId(), loginTime, remoteAddress, user.userId())
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
});
}
private AuthenticatedIdentity toAuthenticatedIdentity(
IdentityUser user,
Instant loginTime,
java.util.List<com.cygnus.cloud.identity.model.MenuItem> menu) {
return new AuthenticatedIdentity(
user.userId(),
user.loginId(),
user.displayName(),
user.groupId(),
user.groupName(),
user.branchId(),
user.branchName(),
user.branchCode(),
user.branchLocation(),
user.companyId(),
user.companyName(),
user.companyCode(),
loginTime,
menu);
}
}

View File

@@ -0,0 +1,35 @@
package com.cygnus.cloud.identity.service;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
/**
* Temporary compatibility verifier for passwords created by the legacy app.
* New passwords must not be written with this algorithm.
*/
@Component
public class LegacyPasswordVerifier {
private static final byte[] LEGACY_KEY =
"ThisIsASecretKey".getBytes(StandardCharsets.US_ASCII);
public boolean matches(String rawPassword, String storedPassword) {
if (rawPassword == null || storedPassword == null) {
return false;
}
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(LEGACY_KEY, "AES"));
byte[] encrypted = cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8));
byte[] expected = Base64.getMimeDecoder().decode(storedPassword);
return MessageDigest.isEqual(encrypted, expected);
} catch (GeneralSecurityException | IllegalArgumentException exception) {
return false;
}
}
}

View File

@@ -0,0 +1,23 @@
package com.cygnus.cloud.identity.service;
import com.cygnus.cloud.cache.ReactiveCacheService;
import java.time.Duration;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class LoginRequestReplayService {
private static final String NAMESPACE = "login-nonce";
private final ReactiveCacheService cache;
public LoginRequestReplayService(ReactiveCacheService cache) {
this.cache = cache;
}
public Mono<Boolean> claim(String installationId, String nonce, Duration ttl) {
return cache.putIfAbsent(
NAMESPACE, installationId + ':' + nonce, "used", ttl);
}
}

View File

@@ -0,0 +1,57 @@
package com.cygnus.cloud.security;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.time.Clock;
import java.time.Instant;
import java.util.Date;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
class AccessTokenIssuer {
private final CommunicationSecurityProperties properties;
private final Clock clock;
AccessTokenIssuer(CommunicationSecurityProperties properties, Clock clock) {
this.properties = properties;
this.clock = clock;
}
TokenResponse issue(MachineClientPrincipal principal, Set<String> scopes) {
try {
Instant issuedAt = clock.instant();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(properties.issuerUri())
.subject(principal.clientId())
.audience(properties.audience())
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(expiresAt))
.jwtID(UUID.randomUUID().toString())
.claim("client_id", principal.clientId())
.claim("installation_id", principal.installationId())
.claim("scope", String.join(" ", scopes))
.build();
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID("cygnus-access-token")
.build(),
claims);
jwt.sign(new RSASSASigner(
PemKeyLoader.privateKey(properties.accessTokenPrivateKey())));
return new TokenResponse(
jwt.serialize(),
"Bearer",
properties.accessTokenTtl().toSeconds(),
String.join(" ", scopes));
} catch (Exception exception) {
throw new IllegalStateException("Unable to issue access token", exception);
}
}
}

View File

@@ -0,0 +1,25 @@
package com.cygnus.cloud.security;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error INVALID_AUDIENCE =
new OAuth2Error("invalid_token", "Required token audience is missing", null);
private final String audience;
AudienceValidator(String audience) {
this.audience = audience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
return jwt.getAudience().contains(audience)
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE);
}
}

View File

@@ -0,0 +1,87 @@
package com.cygnus.cloud.security;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import org.springframework.stereotype.Component;
@Component
class ClientAssertionValidator {
private final CommunicationSecurityProperties properties;
private final Clock clock;
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) {
this.properties = properties;
this.clock = clock;
}
MachineClientPrincipal validate(String clientId, String encryptedAssertion) {
CommunicationSecurityProperties.MachineClient client =
properties.clients() == null ? null : properties.clients().get(clientId);
if (client == null || !client.enabled()) {
throw invalid();
}
try {
JWEObject jwe = JWEObject.parse(encryptedAssertion);
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
|| !EncryptionMethod.A256GCM.equals(jwe.getHeader().getEncryptionMethod())) {
throw invalid();
}
jwe.decrypt(new RSADecrypter(
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
if (!signedJwt.verify(new RSASSAVerifier(
PemKeyLoader.publicKey(client.assertionPublicKey())))) {
throw invalid();
}
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
validateClaims(clientId, client, claims);
return new MachineClientPrincipal(
clientId, client.installationId(), client.scopes());
} catch (MachineAuthenticationException exception) {
throw exception;
} catch (Exception exception) {
throw new MachineAuthenticationException("Invalid client assertion", exception);
}
}
private void validateClaims(
String clientId,
CommunicationSecurityProperties.MachineClient client,
JWTClaimsSet claims) throws Exception {
Instant now = clock.instant();
Date issuedAt = claims.getIssueTime();
Date expiresAt = claims.getExpirationTime();
if (!clientId.equals(claims.getIssuer())
|| !clientId.equals(claims.getSubject())
|| !claims.getAudience().contains(properties.tokenAudience())
|| !client.installationId().equals(
claims.getStringClaim("installation_id"))
|| issuedAt == null
|| expiresAt == null
|| now.isBefore(issuedAt.toInstant().minusSeconds(60))
|| !now.isBefore(expiresAt.toInstant())) {
throw invalid();
}
Duration lifetime = Duration.between(
issuedAt.toInstant(), expiresAt.toInstant());
if (lifetime.isNegative() || lifetime.compareTo(properties.assertionTtl()) > 0) {
throw invalid();
}
}
private MachineAuthenticationException invalid() {
return new MachineAuthenticationException("Invalid client assertion");
}
}

View File

@@ -0,0 +1,51 @@
package com.cygnus.cloud.security;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
public class CloudSecurityConfiguration {
@Bean
SecurityWebFilterChain cloudSecurityFilterChain(
ServerHttpSecurity http,
CommunicationSecurityProperties properties) {
http.csrf(ServerHttpSecurity.CsrfSpec::disable);
if (!properties.enabled()) {
return http.authorizeExchange(exchange -> exchange.anyExchange().permitAll()).build();
}
return http
.authorizeExchange(exchange -> exchange
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
.pathMatchers("/oauth2/token").permitAll()
.pathMatchers("/api/v1/identity/login")
.hasAuthority("SCOPE_identity.login")
.anyExchange().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.build();
}
@Bean
@ConditionalOnProperty(name = "cygnus.security.enabled", havingValue = "true")
ReactiveJwtDecoder reactiveJwtDecoder(CommunicationSecurityProperties properties) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withPublicKey(PemKeyLoader.publicKey(properties.accessTokenPublicKey()))
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<Jwt>(
JwtValidators.createDefaultWithIssuer(properties.issuerUri()),
new AudienceValidator(properties.audience())));
return decoder;
}
}

View File

@@ -0,0 +1,28 @@
package com.cygnus.cloud.security;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.security")
public record CommunicationSecurityProperties(
boolean enabled,
String issuerUri,
String audience,
String tokenAudience,
Duration assertionTtl,
Duration accessTokenTtl,
String assertionDecryptionPrivateKey,
String accessTokenPrivateKey,
String accessTokenPublicKey,
Map<String, MachineClient> clients) {
public record MachineClient(
boolean enabled,
String installationId,
String assertionPublicKey,
Set<String> scopes) {
}
}

View File

@@ -0,0 +1,24 @@
package com.cygnus.cloud.security;
import java.time.Duration;
import com.cygnus.cloud.cache.ReactiveCacheService;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class JwtReplayProtectionService {
private static final String NAMESPACE = "jwt-jti";
private final ReactiveCacheService cache;
public JwtReplayProtectionService(ReactiveCacheService cache) {
this.cache = cache;
}
public Mono<Boolean> claim(String jwtId, Duration remainingLifetime) {
return cache.putIfAbsent(NAMESPACE, jwtId, "used", remainingLifetime);
}
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.cloud.security;
final class MachineAuthenticationException extends RuntimeException {
MachineAuthenticationException(String message) {
super(message);
}
MachineAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.security;
import java.util.Set;
record MachineClientPrincipal(
String clientId,
String installationId,
Set<String> allowedScopes) {
}

View File

@@ -0,0 +1,98 @@
package com.cygnus.cloud.security;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(
prefix = "cygnus.security",
name = "enabled",
havingValue = "true")
class MachineSecurityConfigurationValidator implements InitializingBean {
private final CommunicationSecurityProperties properties;
MachineSecurityConfigurationValidator(
CommunicationSecurityProperties properties) {
this.properties = properties;
}
@Override
public void afterPropertiesSet() {
requireUri("issuer-uri", properties.issuerUri());
requireText("audience", properties.audience());
requireUri("token-audience", properties.tokenAudience());
requirePositive("assertion-ttl", properties.assertionTtl());
requirePositive("access-token-ttl", properties.accessTokenTtl());
requireText(
"assertion-decryption-private-key",
properties.assertionDecryptionPrivateKey());
requireText("access-token-private-key", properties.accessTokenPrivateKey());
requireText("access-token-public-key", properties.accessTokenPublicKey());
Map<String, CommunicationSecurityProperties.MachineClient> clients =
properties.clients();
if (clients == null || clients.isEmpty()) {
throw invalid("at least one machine client is required");
}
clients.forEach(this::validateClient);
}
private void validateClient(
String clientId,
CommunicationSecurityProperties.MachineClient client) {
requireText("clients.<client-id>", clientId);
if (client == null) {
throw invalid("client '" + clientId + "' has no configuration");
}
requireText(
"clients." + clientId + ".installation-id",
client.installationId());
requireText(
"clients." + clientId + ".assertion-public-key",
client.assertionPublicKey());
if (client.scopes() == null
|| client.scopes().isEmpty()
|| client.scopes().stream().anyMatch(this::isBlank)) {
throw invalid(
"clients." + clientId + ".scopes must contain valid scopes");
}
}
private void requireUri(String name, String value) {
requireText(name, value);
try {
URI uri = URI.create(value);
if (!uri.isAbsolute()) {
throw invalid(name + " must be an absolute URI");
}
} catch (IllegalArgumentException exception) {
throw invalid(name + " must be a valid absolute URI");
}
}
private void requirePositive(String name, Duration value) {
if (value == null || value.isZero() || value.isNegative()) {
throw invalid(name + " must be positive");
}
}
private void requireText(String name, String value) {
if (isBlank(value)) {
throw invalid(name + " is required");
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private IllegalStateException invalid(String detail) {
return new IllegalStateException(
"Invalid cygnus.security configuration: " + detail);
}
}

View File

@@ -0,0 +1,69 @@
package com.cygnus.cloud.security;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.http.MediaType;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@RestController
class MachineTokenController {
private static final String CLIENT_CREDENTIALS = "client_credentials";
private static final String ASSERTION_TYPE =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
private final ClientAssertionValidator assertionValidator;
private final AccessTokenIssuer tokenIssuer;
MachineTokenController(
ClientAssertionValidator assertionValidator,
AccessTokenIssuer tokenIssuer) {
this.assertionValidator = assertionValidator;
this.tokenIssuer = tokenIssuer;
}
@PostMapping(
value = "/oauth2/token",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
Mono<Map<String, Object>> token(ServerWebExchange exchange) {
return exchange.getFormData().map(this::issueToken);
}
Map<String, Object> issueToken(MultiValueMap<String, String> form) {
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
throw new MachineAuthenticationException("Unsupported token request");
}
String clientId = required(form, "client_id");
MachineClientPrincipal principal = assertionValidator.validate(
clientId, required(form, "client_assertion"));
Set<String> requestedScopes = scopes(form.getFirst("scope"));
if (requestedScopes.isEmpty()
|| !principal.allowedScopes().containsAll(requestedScopes)) {
throw new MachineAuthenticationException("Invalid requested scope");
}
return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse();
}
private String required(MultiValueMap<String, String> form, String name) {
String value = form.getFirst(name);
if (value == null || value.isBlank()) {
throw new MachineAuthenticationException("Invalid token request");
}
return value;
}
private Set<String> scopes(String value) {
if (value == null || value.isBlank()) {
return Set.of();
}
return new LinkedHashSet<>(Arrays.asList(value.trim().split("\\s+")));
}
}

View File

@@ -0,0 +1,17 @@
package com.cygnus.cloud.security;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = MachineTokenController.class)
class MachineTokenErrorHandler {
@ExceptionHandler(MachineAuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
Map<String, String> invalidClient() {
return Map.of("error", "invalid_client");
}
}

View File

@@ -0,0 +1,83 @@
package com.cygnus.cloud.security;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
final class PemKeyLoader {
private static final ConcurrentHashMap<String, RSAPrivateKey> PRIVATE_KEYS =
new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, RSAPublicKey> PUBLIC_KEYS =
new ConcurrentHashMap<>();
private PemKeyLoader() {
}
static RSAPrivateKey privateKey(String location) {
return PRIVATE_KEYS.computeIfAbsent(location, PemKeyLoader::loadPrivateKey);
}
private static RSAPrivateKey loadPrivateKey(String location) {
try {
String encoded = read(location)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
PrivateKey key = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
return (RSAPrivateKey) key;
} catch (Exception exception) {
throw new IllegalStateException("Unable to load RSA private key", exception);
}
}
static RSAPublicKey publicKey(String location) {
return PUBLIC_KEYS.computeIfAbsent(location, PemKeyLoader::loadPublicKey);
}
private static RSAPublicKey loadPublicKey(String location) {
try {
String encoded = read(location)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
PublicKey key = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
return (RSAPublicKey) key;
} catch (Exception exception) {
throw new IllegalStateException("Unable to load RSA public key", exception);
}
}
private static String read(String location) throws Exception {
if (location == null || location.isBlank()) {
throw new IllegalArgumentException("RSA key location is not configured");
}
if (location.startsWith("classpath:")) {
String resource = location.substring("classpath:".length());
try (InputStream stream = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(resource)) {
if (stream == null) {
throw new IllegalArgumentException("Key resource not found");
}
return new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
}
}
String file = location.startsWith("file:") ? location.substring(5) : location;
return Files.readString(Path.of(file), StandardCharsets.US_ASCII);
}
}

View File

@@ -0,0 +1,20 @@
package com.cygnus.cloud.security;
import java.util.LinkedHashMap;
import java.util.Map;
record TokenResponse(
String accessToken,
String tokenType,
long expiresIn,
String scope) {
Map<String, Object> asOAuthResponse() {
Map<String, Object> response = new LinkedHashMap<>();
response.put("access_token", accessToken);
response.put("token_type", tokenType);
response.put("expires_in", expiresIn);
response.put("scope", scope);
return response;
}
}

View File

@@ -0,0 +1,15 @@
package com.cygnus.cloud.system;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SystemConfiguration {
@Bean
Clock systemClock() {
return Clock.systemUTC();
}
}

View File

@@ -0,0 +1,29 @@
package com.cygnus.cloud.system;
import java.time.Clock;
import java.time.Instant;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/system")
public class SystemInfoController {
private final Clock clock;
public SystemInfoController(Clock clock) {
this.clock = clock;
}
@GetMapping("/info")
public Mono<SystemInfoResponse> info() {
return Mono.just(new SystemInfoResponse(
"cygnus-cloud-service",
"1.0.0-SNAPSHOT",
Instant.now(clock)));
}
}

View File

@@ -0,0 +1,6 @@
package com.cygnus.cloud.system;
import java.time.Instant;
public record SystemInfoResponse(String service, String version, Instant timestamp) {
}

View File

@@ -0,0 +1,64 @@
spring:
application:
name: cygnus-cloud-service
data:
redis:
host: ${REDIS_HOST:192.168.0.111}
port: ${REDIS_PORT:7901}
password: ${REDIS_PASSWORD:}
connect-timeout: ${REDIS_CONNECT_TIMEOUT:3s}
timeout: ${REDIS_COMMAND_TIMEOUT:3s}
lettuce:
pool:
max-active: ${REDIS_MAX_ACTIVE:20}
max-idle: ${REDIS_MAX_IDLE:10}
min-idle: ${REDIS_MIN_IDLE:1}
max-wait: ${REDIS_MAX_WAIT:2s}
cygnus:
database:
host: ${DB_HOST:192.168.0.111}
port: ${DB_PORT:5432}
database: ${DB_NAME:matrix}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:}
ssl: ${DB_SSL:false}
connect-timeout: ${DB_CONNECT_TIMEOUT:3s}
pool-size: ${DB_POOL_SIZE:20}
pool-wait-queue-size: ${DB_POOL_WAIT_QUEUE_SIZE:100}
security:
enabled: ${CYGNUS_SECURITY_ENABLED:false}
issuer-uri: ${CYGNUS_JWT_ISSUER_URI:http://localhost:8090}
audience: ${CYGNUS_JWT_AUDIENCE:cygnus-cloud-api}
token-audience: ${CYGNUS_TOKEN_AUDIENCE:http://localhost:8090/oauth2/token}
assertion-ttl: ${CYGNUS_ASSERTION_TTL:370d}
access-token-ttl: ${CYGNUS_ACCESS_TOKEN_TTL:20m}
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
clients: {}
login-encryption:
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
payload-ttl: ${CYGNUS_LOGIN_PAYLOAD_TTL:5m}
cache:
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
default-ttl: ${CYGNUS_CACHE_TTL:10m}
server:
port: ${CYGNUS_CLOUD_PORT:8090}
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never
info:
app:
name: ${spring.application.name}
version: 1.0.0-SNAPSHOT

View File

@@ -0,0 +1,217 @@
BEGIN;
CREATE SCHEMA IF NOT EXISTS identity;
CREATE TABLE IF NOT EXISTS identity.company
(LIKE public.company INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.company_branch
(LIKE public.company_branch INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.user_group
(LIKE public.user_group INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.app_user
(LIKE public.app_user INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.pages
(LIKE public.pages INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.permission
(LIKE public.permission INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.denied_pages
(LIKE public.denied_pages INCLUDING ALL);
CREATE TABLE IF NOT EXISTS identity.user_loginhistory
(LIKE public.user_loginhistory INCLUDING ALL);
CREATE SEQUENCE IF NOT EXISTS identity.app_user_user_id_seq
AS smallint MAXVALUE 32767;
CREATE SEQUENCE IF NOT EXISTS identity.company_company_id_seq
AS smallint MAXVALUE 32767;
CREATE SEQUENCE IF NOT EXISTS identity.company_branch_branch_id_seq
AS smallint MAXVALUE 32767;
CREATE SEQUENCE IF NOT EXISTS identity.user_group_group_id_seq
AS smallint MAXVALUE 32767;
CREATE SEQUENCE IF NOT EXISTS identity.pages_page_id_seq
AS smallint MAXVALUE 32767;
CREATE SEQUENCE IF NOT EXISTS identity.permission_permission_id_seq
AS integer;
CREATE SEQUENCE IF NOT EXISTS identity.denied_pages_uid_seq
AS integer;
CREATE SEQUENCE IF NOT EXISTS identity.user_loginhistory_uid_seq
AS bigint;
ALTER TABLE identity.app_user
ALTER COLUMN user_id SET DEFAULT nextval('identity.app_user_user_id_seq');
ALTER TABLE identity.company
ALTER COLUMN company_id SET DEFAULT nextval('identity.company_company_id_seq');
ALTER TABLE identity.company_branch
ALTER COLUMN branch_id SET DEFAULT nextval('identity.company_branch_branch_id_seq');
ALTER TABLE identity.user_group
ALTER COLUMN group_id SET DEFAULT nextval('identity.user_group_group_id_seq');
ALTER TABLE identity.pages
ALTER COLUMN page_id SET DEFAULT nextval('identity.pages_page_id_seq');
ALTER TABLE identity.permission
ALTER COLUMN permission_id SET DEFAULT nextval('identity.permission_permission_id_seq');
ALTER TABLE identity.denied_pages
ALTER COLUMN uid SET DEFAULT nextval('identity.denied_pages_uid_seq');
ALTER TABLE identity.user_loginhistory
ALTER COLUMN uid SET DEFAULT nextval('identity.user_loginhistory_uid_seq');
ALTER TABLE identity.user_loginhistory
ALTER COLUMN ipaddr TYPE character varying(45);
INSERT INTO identity.company
SELECT * FROM public.company
ON CONFLICT (company_id) DO UPDATE SET
companyname = EXCLUDED.companyname,
companycode = EXCLUDED.companycode,
servicetaxno = EXCLUDED.servicetaxno,
panno = EXCLUDED.panno,
createdon = EXCLUDED.createdon,
isactive = EXCLUDED.isactive,
cinno = EXCLUDED.cinno;
INSERT INTO identity.company_branch
SELECT * FROM public.company_branch
ON CONFLICT (branch_id) DO UPDATE SET
company_id = EXCLUDED.company_id,
branchname = EXCLUDED.branchname,
branchcode = EXCLUDED.branchcode,
address1 = EXCLUDED.address1,
address2 = EXCLUDED.address2,
address3 = EXCLUDED.address3,
city = EXCLUDED.city,
pincode = EXCLUDED.pincode,
landlineno = EXCLUDED.landlineno,
faxno = EXCLUDED.faxno,
emailid = EXCLUDED.emailid,
contactperson1 = EXCLUDED.contactperson1,
contactno1 = EXCLUDED.contactno1,
emailid1 = EXCLUDED.emailid1,
contactperson2 = EXCLUDED.contactperson2,
contactno2 = EXCLUDED.contactno2,
emailid2 = EXCLUDED.emailid2,
isactive = EXCLUDED.isactive,
createdon = EXCLUDED.createdon,
gstin = EXCLUDED.gstin,
cgst = EXCLUDED.cgst,
sgst = EXCLUDED.sgst,
igst = EXCLUDED.igst;
INSERT INTO identity.user_group
SELECT * FROM public.user_group
ON CONFLICT (group_id) DO UPDATE SET
name = EXCLUDED.name,
department_id = EXCLUDED.department_id,
createdon = EXCLUDED.createdon,
createdby = EXCLUDED.createdby,
lasteditedon = EXCLUDED.lasteditedon,
lasteditedby = EXCLUDED.lasteditedby,
isactive = EXCLUDED.isactive;
INSERT INTO identity.app_user
SELECT * FROM public.app_user
ON CONFLICT (user_id) DO UPDATE SET
loginid = EXCLUDED.loginid,
displayname = EXCLUDED.displayname,
loginpassword = EXCLUDED.loginpassword,
group_id = EXCLUDED.group_id,
createdon = EXCLUDED.createdon,
createdby = EXCLUDED.createdby,
activatedon = EXCLUDED.activatedon,
activatedby = EXCLUDED.activatedby,
lasteditedon = EXCLUDED.lasteditedon,
lasteditedby = EXCLUDED.lasteditedby,
branch_id = EXCLUDED.branch_id,
company_id = EXCLUDED.company_id,
isactive = EXCLUDED.isactive,
team_id = EXCLUDED.team_id,
emailid = EXCLUDED.emailid,
entry_time = EXCLUDED.entry_time;
INSERT INTO identity.pages
SELECT * FROM public.pages
ON CONFLICT (page_id) DO UPDATE SET
menulabel = EXCLUDED.menulabel,
targeturl = EXCLUDED.targeturl,
parentpage = EXCLUDED.parentpage,
targetwindow = EXCLUDED.targetwindow,
pageorder = EXCLUDED.pageorder,
isvisible = EXCLUDED.isvisible;
INSERT INTO identity.permission
SELECT * FROM public.permission
ON CONFLICT (permission_id) DO UPDATE SET
page_id = EXCLUDED.page_id,
group_id = EXCLUDED.group_id,
lasteditedon = EXCLUDED.lasteditedon,
lasteditedby = EXCLUDED.lasteditedby,
permission = EXCLUDED.permission,
requestval = EXCLUDED.requestval;
INSERT INTO identity.denied_pages
SELECT * FROM public.denied_pages
ON CONFLICT (uid) DO UPDATE SET
page_id = EXCLUDED.page_id,
user_id = EXCLUDED.user_id,
isdenied = EXCLUDED.isdenied;
INSERT INTO identity.user_loginhistory
SELECT * FROM public.user_loginhistory
ON CONFLICT (uid) DO UPDATE SET
loginid = EXCLUDED.loginid,
logintime = EXCLUDED.logintime,
logouttime = EXCLUDED.logouttime,
ipaddr = EXCLUDED.ipaddr,
user_id = EXCLUDED.user_id;
CREATE INDEX IF NOT EXISTS ix_identity_app_user_loginid_ci
ON identity.app_user (upper(loginid));
CREATE INDEX IF NOT EXISTS ix_identity_permission_group_page
ON identity.permission (group_id, page_id)
WHERE permission <> '000';
CREATE INDEX IF NOT EXISTS ix_identity_pages_menu
ON identity.pages (parentpage, pageorder DESC)
WHERE isvisible = 1;
CREATE INDEX IF NOT EXISTS ix_identity_denied_pages_user_page
ON identity.denied_pages (user_id, page_id)
WHERE isdenied = 1;
CREATE INDEX IF NOT EXISTS ix_identity_login_history_user_time
ON identity.user_loginhistory (user_id, logintime DESC);
SELECT setval(
'identity.app_user_user_id_seq',
GREATEST(COALESCE((SELECT max(user_id) FROM identity.app_user), 1), 1),
EXISTS (SELECT 1 FROM identity.app_user));
SELECT setval(
'identity.company_company_id_seq',
GREATEST(COALESCE((SELECT max(company_id) FROM identity.company), 1), 1),
EXISTS (SELECT 1 FROM identity.company));
SELECT setval(
'identity.company_branch_branch_id_seq',
GREATEST(COALESCE((SELECT max(branch_id) FROM identity.company_branch), 1), 1),
EXISTS (SELECT 1 FROM identity.company_branch));
SELECT setval(
'identity.user_group_group_id_seq',
GREATEST(COALESCE((SELECT max(group_id) FROM identity.user_group), 1), 1),
EXISTS (SELECT 1 FROM identity.user_group));
SELECT setval(
'identity.pages_page_id_seq',
GREATEST(COALESCE((SELECT max(page_id) FROM identity.pages), 1), 1),
EXISTS (SELECT 1 FROM identity.pages));
SELECT setval(
'identity.permission_permission_id_seq',
GREATEST(COALESCE((SELECT max(permission_id) FROM identity.permission), 1), 1),
EXISTS (SELECT 1 FROM identity.permission));
SELECT setval(
'identity.denied_pages_uid_seq',
GREATEST(COALESCE((SELECT max(uid) FROM identity.denied_pages), 1), 1),
EXISTS (SELECT 1 FROM identity.denied_pages));
SELECT setval(
'identity.user_loginhistory_uid_seq',
GREATEST(COALESCE((SELECT max(uid) FROM identity.user_loginhistory), 1), 1),
EXISTS (SELECT 1 FROM identity.user_loginhistory));
COMMIT;

View File

@@ -0,0 +1,42 @@
package com.cygnus.cloud;
import static org.assertj.core.api.Assertions.assertThat;
import com.cygnus.cloud.cache.ReactiveCacheService;
import com.cygnus.cloud.database.DatabaseProperties;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.security.CommunicationSecurityProperties;
import io.vertx.sqlclient.Pool;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "cygnus.security.enabled=false")
class InfrastructureConfigurationTest {
@Autowired
private Pool postgresPool;
@Autowired
private ReactiveDatabaseClient databaseClient;
@Autowired
private ReactiveCacheService cacheService;
@Autowired
private DatabaseProperties databaseProperties;
@Autowired
private CommunicationSecurityProperties securityProperties;
@Test
void communicationInfrastructureStartsWithoutOpeningExternalConnections() {
assertThat(postgresPool).isNotNull();
assertThat(databaseClient).isNotNull();
assertThat(cacheService).isNotNull();
assertThat(databaseProperties.database()).isEqualTo("matrix");
assertThat(securityProperties.audience()).isEqualTo("cygnus-cloud-api");
}
}

View File

@@ -0,0 +1,95 @@
package com.cygnus.cloud.identity.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.model.MenuItem;
import com.cygnus.cloud.identity.repository.IdentityRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ExtendWith(MockitoExtension.class)
class IdentityAuthenticationServiceTest {
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z");
@Mock
private IdentityRepository repository;
@Mock
private LegacyPasswordVerifier passwordVerifier;
@Test
void authenticatesActiveUserAndReturnsStructuredSessionData() {
IdentityUser user = user("maddy", true);
MenuItem menuItem = new MenuItem(
(short) 10, "Operations", "/ver/operations", (short) 0,
(short) 1, "110", "_parent", null);
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true);
when(repository.findMenu((short) 4, (short) 25))
.thenReturn(Mono.just(List.of(menuItem)));
when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25))
.thenReturn(Mono.just(101L));
IdentityAuthenticationService service = new IdentityAuthenticationService(
repository,
passwordVerifier,
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
.assertNext(result -> {
assertThat(result.loginId()).isEqualTo("maddy");
assertThat(result.companyName()).isEqualTo("Matrix");
assertThat(result.menu()).containsExactly(menuItem);
assertThat(result.loginTime()).isEqualTo(LOGIN_TIME);
})
.verifyComplete();
verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25);
}
@Test
void rejectsInactiveUserWithoutLoadingMenu() {
IdentityUser user = user("maddy", false);
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
IdentityAuthenticationService service = new IdentityAuthenticationService(
repository,
passwordVerifier,
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
.expectError(AuthenticationException.class)
.verify();
}
private IdentityUser user(String loginId, boolean active) {
return new IdentityUser(
(short) 25,
loginId,
"Maddy",
"legacy-value",
(short) 4,
"Administrator",
(short) 2,
"Delhi",
"DEL",
"Delhi",
(short) 1,
"Matrix",
"MCR",
active);
}
}

View File

@@ -0,0 +1,16 @@
package com.cygnus.cloud.identity.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class LegacyPasswordVerifierTest {
private final LegacyPasswordVerifier verifier = new LegacyPasswordVerifier();
@Test
void matchesLegacyAesPassword() {
assertThat(verifier.matches("password", "sS3vFSMkpzsHrGYlS1Nn6Q==")).isTrue();
assertThat(verifier.matches("wrong", "sS3vFSMkpzsHrGYlS1Nn6Q==")).isFalse();
}
}

View File

@@ -0,0 +1,220 @@
package com.cygnus.cloud.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSAEncrypter;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.BodyInserters;
class MachineTokenFlowTest {
private static final Instant NOW = Instant.parse("2026-07-23T06:30:00Z");
private static final String CLIENT_ID = "customer-a";
private static final String INSTALLATION_ID = "site-01";
private static final String TOKEN_AUDIENCE =
"https://cloud.example.test/oauth2/token";
@TempDir
Path tempDirectory;
private KeyPair assertionEncryptionKeys;
private KeyPair clientSigningKeys;
private KeyPair accessTokenKeys;
private CommunicationSecurityProperties properties;
@BeforeEach
void setUp() throws Exception {
assertionEncryptionKeys = keyPair();
clientSigningKeys = keyPair();
accessTokenKeys = keyPair();
properties = new CommunicationSecurityProperties(
true,
"https://cloud.example.test",
"cygnus-cloud-api",
TOKEN_AUDIENCE,
Duration.ofDays(370),
Duration.ofMinutes(20),
privatePem("assertion-private.pem", assertionEncryptionKeys),
privatePem("access-private.pem", accessTokenKeys),
publicPem("access-public.pem", accessTokenKeys),
Map.of(
CLIENT_ID,
new CommunicationSecurityProperties.MachineClient(
true,
INSTALLATION_ID,
publicPem("client-public.pem", clientSigningKeys),
Set.of("identity.login"))));
}
@Test
void validatesNestedAssertionAndIssuesBoundShortLivedAccessToken()
throws Exception {
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
ClientAssertionValidator validator =
new ClientAssertionValidator(properties, clock);
AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock);
MachineTokenController controller =
new MachineTokenController(validator, issuer);
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "client_credentials");
form.add("client_id", CLIENT_ID);
form.add(
"client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
form.add("scope", "identity.login");
Map<String, Object> response = controller.issueToken(form);
SignedJWT token = SignedJWT.parse((String) response.get("access_token"));
assertThat(token.verify(new RSASSAVerifier(
(RSAPublicKey) accessTokenKeys.getPublic())))
.isTrue();
assertThat(token.getJWTClaimsSet().getStringClaim("client_id"))
.isEqualTo(CLIENT_ID);
assertThat(token.getJWTClaimsSet().getStringClaim("installation_id"))
.isEqualTo(INSTALLATION_ID);
assertThat(token.getJWTClaimsSet().getStringClaim("scope"))
.isEqualTo("identity.login");
assertThat(response)
.containsEntry("token_type", "Bearer")
.containsEntry("expires_in", 1200L)
.containsEntry("scope", "identity.login");
}
@Test
void rejectsExpiredAssertion() throws Exception {
ClientAssertionValidator validator = new ClientAssertionValidator(
properties, Clock.fixed(NOW, ZoneOffset.UTC));
assertThatThrownBy(() -> validator.validate(
CLIENT_ID,
encryptedAssertion(
NOW.minus(Duration.ofDays(366)),
NOW.minusSeconds(1))))
.isInstanceOf(MachineAuthenticationException.class);
}
@Test
void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception {
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
MachineTokenController controller = new MachineTokenController(
new ClientAssertionValidator(properties, clock),
new AccessTokenIssuer(properties, clock));
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "client_credentials");
form.add("client_id", CLIENT_ID);
form.add(
"client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
form.add(
"client_assertion",
encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
form.add("scope", "identity.login");
WebTestClient.bindToController(controller)
.build()
.post()
.uri("/oauth2/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(form))
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.access_token").isNotEmpty()
.jsonPath("$.token_type").isEqualTo("Bearer")
.jsonPath("$.expires_in").isEqualTo(1200)
.jsonPath("$.scope").isEqualTo("identity.login");
}
private String encryptedAssertion(Instant issuedAt, Instant expiresAt)
throws Exception {
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(CLIENT_ID)
.subject(CLIENT_ID)
.audience(TOKEN_AUDIENCE)
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(expiresAt))
.claim("installation_id", INSTALLATION_ID)
.build();
SignedJWT signed = new SignedJWT(
new JWSHeader(JWSAlgorithm.RS256), claims);
signed.sign(new RSASSASigner(
(RSAPrivateKey) clientSigningKeys.getPrivate()));
JWEObject encrypted = new JWEObject(
new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM),
new Payload(signed.serialize()));
encrypted.encrypt(new RSAEncrypter(
(RSAPublicKey) assertionEncryptionKeys.getPublic()));
return encrypted.serialize();
}
private KeyPair keyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
private String privatePem(String name, KeyPair pair) throws Exception {
return writePem(
name,
"PRIVATE KEY",
pair.getPrivate().getEncoded());
}
private String publicPem(String name, KeyPair pair) throws Exception {
return writePem(
name,
"PUBLIC KEY",
pair.getPublic().getEncoded());
}
private String writePem(String name, String type, byte[] key) throws Exception {
String body = Base64.getMimeEncoder(64, new byte[] {'\n'})
.encodeToString(key);
Path path = tempDirectory.resolve(name);
Files.writeString(
path,
"-----BEGIN " + type + "-----\n"
+ body
+ "\n-----END " + type + "-----\n",
StandardCharsets.US_ASCII);
return "file:" + path;
}
}

View File

@@ -0,0 +1,45 @@
package com.cygnus.cloud.system;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webflux.test.autoconfigure.WebFluxTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.test.web.reactive.server.WebTestClient;
@WebFluxTest(SystemInfoController.class)
@Import(SystemInfoControllerTest.FixedClockConfiguration.class)
class SystemInfoControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
void exposesVersionedServiceInformation() {
webTestClient.get()
.uri("/api/v1/system/info")
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith("application/json")
.expectBody()
.jsonPath("$.service").isEqualTo("cygnus-cloud-service")
.jsonPath("$.version").isEqualTo("1.0.0-SNAPSHOT")
.jsonPath("$.timestamp").isEqualTo("2026-07-22T12:00:00Z");
}
@TestConfiguration
static class FixedClockConfiguration {
@Bean
@Primary
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-07-22T12:00:00Z"), ZoneOffset.UTC);
}
}
}

View File

@@ -0,0 +1,64 @@
spring:
application:
name: cygnus-cloud-service
data:
redis:
host: ${REDIS_HOST:192.168.0.111}
port: ${REDIS_PORT:7901}
password: ${REDIS_PASSWORD:}
connect-timeout: ${REDIS_CONNECT_TIMEOUT:3s}
timeout: ${REDIS_COMMAND_TIMEOUT:3s}
lettuce:
pool:
max-active: ${REDIS_MAX_ACTIVE:20}
max-idle: ${REDIS_MAX_IDLE:10}
min-idle: ${REDIS_MIN_IDLE:1}
max-wait: ${REDIS_MAX_WAIT:2s}
cygnus:
database:
host: ${DB_HOST:192.168.0.111}
port: ${DB_PORT:5432}
database: ${DB_NAME:matrix}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:}
ssl: ${DB_SSL:false}
connect-timeout: ${DB_CONNECT_TIMEOUT:3s}
pool-size: ${DB_POOL_SIZE:20}
pool-wait-queue-size: ${DB_POOL_WAIT_QUEUE_SIZE:100}
security:
enabled: ${CYGNUS_SECURITY_ENABLED:false}
issuer-uri: ${CYGNUS_JWT_ISSUER_URI:http://localhost:8090}
audience: ${CYGNUS_JWT_AUDIENCE:cygnus-cloud-api}
token-audience: ${CYGNUS_TOKEN_AUDIENCE:http://localhost:8090/oauth2/token}
assertion-ttl: ${CYGNUS_ASSERTION_TTL:370d}
access-token-ttl: ${CYGNUS_ACCESS_TOKEN_TTL:20m}
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
clients: {}
login-encryption:
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
payload-ttl: ${CYGNUS_LOGIN_PAYLOAD_TTL:5m}
cache:
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
default-ttl: ${CYGNUS_CACHE_TTL:10m}
server:
port: ${CYGNUS_CLOUD_PORT:8090}
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never
info:
app:
name: ${spring.application.name}
version: 1.0.0-SNAPSHOT

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