diff --git a/.DS_Store b/.DS_Store
index 72e25f8..9f272c1 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index af9267e..596f6c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/target/
+/config/
!.mvn/wrapper/maven-wrapper.jar
### STS ###
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 740d321..c253694 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,6 +1,39 @@
{
"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)",
@@ -13,7 +46,23 @@
"$Test"
],
"env": {
- "JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"
+ "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"
diff --git a/Cygnus_Architecture_Overview.svg b/Cygnus_Architecture_Overview.svg
new file mode 100644
index 0000000..a331446
--- /dev/null
+++ b/Cygnus_Architecture_Overview.svg
@@ -0,0 +1,359 @@
+
+
diff --git a/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx b/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx
new file mode 100644
index 0000000..e3119de
Binary files /dev/null and b/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx differ
diff --git a/cygnus-cloud-client/README.md b/cygnus-cloud-client/README.md
new file mode 100644
index 0000000..7bcb53d
--- /dev/null
+++ b/cygnus-cloud-client/README.md
@@ -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.
diff --git a/cygnus-cloud-client/pom.xml b/cygnus-cloud-client/pom.xml
new file mode 100644
index 0000000..97cfe26
--- /dev/null
+++ b/cygnus-cloud-client/pom.xml
@@ -0,0 +1,57 @@
+
+
+ 4.0.0
+
+
+ com.cygnus
+ cygnus-parent
+ 1.0.0-SNAPSHOT
+ ../pom.xml
+
+
+ cygnus-cloud-client
+ jar
+
+ Cygnus Cloud Client
+
+
+
+ org.springframework
+ spring-webflux
+ ${spring.version}
+
+
+ io.projectreactor.netty
+ reactor-netty-http
+ ${reactor.netty.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ com.nimbusds
+ nimbus-jose-jwt
+ ${nimbus.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.5.0
+
+
+
+
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientFactory.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientFactory.java
new file mode 100644
index 0000000..ad5c829
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientFactory.java
@@ -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);
+ }
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientProperties.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientProperties.java
new file mode 100644
index 0000000..c0da247
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientProperties.java
@@ -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 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 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;
+ }
+
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudIdentityClient.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudIdentityClient.java
new file mode 100644
index 0000000..992f971
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudIdentityClient.java
@@ -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 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());
+ }
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudIdentitySession.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudIdentitySession.java
new file mode 100644
index 0000000..334757f
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudIdentitySession.java
@@ -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 menu) {
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudMenuItem.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudMenuItem.java
new file mode 100644
index 0000000..91fdfb8
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudMenuItem.java
@@ -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) {
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/model/EncryptedLoginRequest.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/EncryptedLoginRequest.java
new file mode 100644
index 0000000..27079b1
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/EncryptedLoginRequest.java
@@ -0,0 +1,8 @@
+package com.cygnus.client.model;
+
+public record EncryptedLoginRequest(
+ String keyId,
+ String encryptedKey,
+ String initializationVector,
+ String encryptedPayload) {
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/model/LoginPayload.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/LoginPayload.java
new file mode 100644
index 0000000..1f4d6b1
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/model/LoginPayload.java
@@ -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) {
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/provisioning/MachineAssertionGenerator.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/provisioning/MachineAssertionGenerator.java
new file mode 100644
index 0000000..f7e6969
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/provisioning/MachineAssertionGenerator.java
@@ -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: "
+ + " "
+ + " ");
+ }
+ 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", "");
+ }
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/security/CloudClientException.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/CloudClientException.java
new file mode 100644
index 0000000..5e6ca38
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/CloudClientException.java
@@ -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);
+ }
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/security/LoginEnvelopeEncryptor.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/LoginEnvelopeEncryptor.java
new file mode 100644
index 0000000..5dfa49c
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/LoginEnvelopeEncryptor.java
@@ -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;
+ }
+ }
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/security/MachineTokenProvider.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/MachineTokenProvider.java
new file mode 100644
index 0000000..7b0850e
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/MachineTokenProvider.java
@@ -0,0 +1,7 @@
+package com.cygnus.client.security;
+
+import reactor.core.publisher.Mono;
+
+public interface MachineTokenProvider {
+ Mono accessToken();
+}
diff --git a/cygnus-cloud-client/src/main/java/com/cygnus/client/security/OAuthMachineTokenProvider.java b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/OAuthMachineTokenProvider.java
new file mode 100644
index 0000000..ec95a76
--- /dev/null
+++ b/cygnus-cloud-client/src/main/java/com/cygnus/client/security/OAuthMachineTokenProvider.java
@@ -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 = new AtomicReference<>();
+ private volatile Mono tokenRequest;
+
+ public OAuthMachineTokenProvider(
+ WebClient webClient, CloudClientProperties properties, Clock clock) {
+ this.webClient = webClient;
+ this.properties = properties;
+ this.clock = clock;
+ }
+
+ @Override
+ public Mono 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 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) {
+ }
+}
diff --git a/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java b/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java
new file mode 100644
index 0000000..0acfe55
--- /dev/null
+++ b/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java
@@ -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));
+ }
+}
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/CloudClientFactory.class b/cygnus-cloud-client/target/classes/com/cygnus/client/CloudClientFactory.class
new file mode 100644
index 0000000..6c6ae84
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/CloudClientFactory.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/CloudClientProperties.class b/cygnus-cloud-client/target/classes/com/cygnus/client/CloudClientProperties.class
new file mode 100644
index 0000000..0cfe47d
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/CloudClientProperties.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/CloudIdentityClient.class b/cygnus-cloud-client/target/classes/com/cygnus/client/CloudIdentityClient.class
new file mode 100644
index 0000000..8e0cb76
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/CloudIdentityClient.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/model/CloudIdentitySession.class b/cygnus-cloud-client/target/classes/com/cygnus/client/model/CloudIdentitySession.class
new file mode 100644
index 0000000..015a426
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/model/CloudIdentitySession.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/model/CloudMenuItem.class b/cygnus-cloud-client/target/classes/com/cygnus/client/model/CloudMenuItem.class
new file mode 100644
index 0000000..34800ce
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/model/CloudMenuItem.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/model/EncryptedLoginRequest.class b/cygnus-cloud-client/target/classes/com/cygnus/client/model/EncryptedLoginRequest.class
new file mode 100644
index 0000000..7335994
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/model/EncryptedLoginRequest.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/model/LoginPayload.class b/cygnus-cloud-client/target/classes/com/cygnus/client/model/LoginPayload.class
new file mode 100644
index 0000000..18f75cb
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/model/LoginPayload.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/provisioning/MachineAssertionGenerator.class b/cygnus-cloud-client/target/classes/com/cygnus/client/provisioning/MachineAssertionGenerator.class
new file mode 100644
index 0000000..6b8dbaa
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/provisioning/MachineAssertionGenerator.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/security/CloudClientException.class b/cygnus-cloud-client/target/classes/com/cygnus/client/security/CloudClientException.class
new file mode 100644
index 0000000..ab732ba
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/security/CloudClientException.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/security/LoginEnvelopeEncryptor.class b/cygnus-cloud-client/target/classes/com/cygnus/client/security/LoginEnvelopeEncryptor.class
new file mode 100644
index 0000000..814ed73
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/security/LoginEnvelopeEncryptor.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/security/MachineTokenProvider.class b/cygnus-cloud-client/target/classes/com/cygnus/client/security/MachineTokenProvider.class
new file mode 100644
index 0000000..4746104
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/security/MachineTokenProvider.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider$CachedToken.class b/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider$CachedToken.class
new file mode 100644
index 0000000..5627ae5
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider$CachedToken.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider$TokenResponse.class b/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider$TokenResponse.class
new file mode 100644
index 0000000..5376457
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider$TokenResponse.class differ
diff --git a/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider.class b/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider.class
new file mode 100644
index 0000000..cb5a062
Binary files /dev/null and b/cygnus-cloud-client/target/classes/com/cygnus/client/security/OAuthMachineTokenProvider.class differ
diff --git a/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar b/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar
new file mode 100644
index 0000000..78e52c2
Binary files /dev/null and b/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar differ
diff --git a/cygnus-cloud-client/target/maven-archiver/pom.properties b/cygnus-cloud-client/target/maven-archiver/pom.properties
new file mode 100644
index 0000000..3594f36
--- /dev/null
+++ b/cygnus-cloud-client/target/maven-archiver/pom.properties
@@ -0,0 +1,3 @@
+artifactId=cygnus-cloud-client
+groupId=com.cygnus
+version=1.0.0-SNAPSHOT
diff --git a/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
new file mode 100644
index 0000000..7e3e428
--- /dev/null
+++ b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
@@ -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
diff --git a/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
new file mode 100644
index 0000000..9c60702
--- /dev/null
+++ b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
@@ -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
diff --git a/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst
new file mode 100644
index 0000000..28e553e
--- /dev/null
+++ b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst
@@ -0,0 +1 @@
+com/cygnus/client/CloudClientPropertiesTest.class
diff --git a/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst
new file mode 100644
index 0000000..738276e
--- /dev/null
+++ b/cygnus-cloud-client/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst
@@ -0,0 +1 @@
+/Users/maddy/Projects/matrix/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java
diff --git a/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml b/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml
new file mode 100644
index 0000000..42cfb3d
--- /dev/null
+++ b/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cygnus-cloud-client/target/surefire-reports/com.cygnus.client.CloudClientPropertiesTest.txt b/cygnus-cloud-client/target/surefire-reports/com.cygnus.client.CloudClientPropertiesTest.txt
new file mode 100644
index 0000000..6a1f317
--- /dev/null
+++ b/cygnus-cloud-client/target/surefire-reports/com.cygnus.client.CloudClientPropertiesTest.txt
@@ -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
diff --git a/cygnus-cloud-client/target/test-classes/com/cygnus/client/CloudClientPropertiesTest.class b/cygnus-cloud-client/target/test-classes/com/cygnus/client/CloudClientPropertiesTest.class
new file mode 100644
index 0000000..d496bad
Binary files /dev/null and b/cygnus-cloud-client/target/test-classes/com/cygnus/client/CloudClientPropertiesTest.class differ
diff --git a/cygnus-cloud-service/README.md b/cygnus-cloud-service/README.md
new file mode 100644
index 0000000..4c5028d
--- /dev/null
+++ b/cygnus-cloud-service/README.md
@@ -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.
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java
new file mode 100644
index 0000000..277e51b
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java
@@ -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 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();
+ }
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/EncryptedLoginRequest.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/EncryptedLoginRequest.java
new file mode 100644
index 0000000..c333def
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/EncryptedLoginRequest.java
@@ -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) {
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/IdentityErrorHandler.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/IdentityErrorHandler.java
new file mode 100644
index 0000000..a05f89e
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/IdentityErrorHandler.java
@@ -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 authenticationFailure() {
+ return Map.of("code", "AUTHENTICATION_FAILED", "message", "Authentication failed");
+ }
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/LoginEncryptionProperties.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/LoginEncryptionProperties.java
new file mode 100644
index 0000000..ace5d47
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/LoginEncryptionProperties.java
@@ -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) {
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/LoginPayload.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/LoginPayload.java
new file mode 100644
index 0000000..e183f31
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/LoginPayload.java
@@ -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) {
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/RsaLoginPayloadDecryptor.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/RsaLoginPayloadDecryptor.java
new file mode 100644
index 0000000..c35643e
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/RsaLoginPayloadDecryptor.java
@@ -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;
+ }
+ }
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/model/AuthenticatedIdentity.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/model/AuthenticatedIdentity.java
new file mode 100644
index 0000000..0c55848
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/model/AuthenticatedIdentity.java
@@ -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