**vaessl: Login Architecture** # Backend The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance are used to keep refactorings to a minimum. The first app to bridge is Homebox, which uses a classic username/password and Bearer token login process. The second hypothetical app, WikiJS, uses a user-generated API key. The abstractions cater to both authentication methods. ## API Endpoints The server context path is `/api`. The three endpoints that drive the connection flow: | Method | Path | Description | |--------|------|-------------| | `POST` | `/api/login` | Authenticate a service; stores connection ID in session | | `GET` | `/api/connections/status` | List connected services for the current session | | `DELETE` | `/api/connections/{serviceType}` | Remove a service from the session; invalidates the session if no connections remain | ## Session Management Authentication state is held server-side in a JDBC-backed Spring Session (stored in the same PostgreSQL database). After a successful login the controller stores `{serviceType}_CONNECTION_ID` in the `HttpSession`. The session cookie is `HttpOnly`, `SameSite=Lax`, and set to max age. `CorsConfig` allows credentials from the two configured origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`). `allowCredentials(true)` is required for the session cookie to travel cross-origin. ## Single Table Inheritance Database entities use Single Table Inheritance — one `connections` table with a `service_type` discriminator column and all app-specific nullable columns. A unique constraint on `(appUrl, username)` prevents duplicate entries per user per instance. ***ConnectionEntity.java*** ```java @Entity @Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) }) @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "service_type") public abstract class ConnectionEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String appUrl; private String username; } ``` ***HomeboxEntity.java*** ```java @Entity @DiscriminatorValue("HOMEBOX") public class HomeboxEntity extends ConnectionEntity { private String token; private String attachmentToken; private Instant expiresAt; public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) { HomeboxEntity he = new HomeboxEntity(); he.setAppUrl(request.appUrl()); he.setUsername(request.username()); he.setToken(response.token()); he.setAttachmentToken(response.getExtraVar("attachmentToken")); he.setExpiresAt(response.expiresAt()); return he; } } ``` ## The Provider Pattern (Logic Layer) Each integrated app implements `ConnectionProvider`. This separates how an app authenticates (the specific) from what the system does with that result (the general). ***ConnectionProvider.java*** ```java public interface ConnectionProvider { String getServiceType(); void checkCredentials(ConnectionRequest request); ConnectionResponse authenticate(ConnectionRequest request); ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response); default Instant getTokenExpiry(ConnectionEntity entity) { return null; } } ``` - `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields) - `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update - `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag ***HomeboxConnectionProvider.java*** ```java @Component public class HomeboxConnectionProvider implements ConnectionProvider { @Override public String getServiceType() { return "HOMEBOX"; } @Override public void checkCredentials(ConnectionRequest request) { // throws EmptyCredentialsException if username or password is null } @Override public ConnectionResponse authenticate(ConnectionRequest request) { // POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt } @Override public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) { return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); } @Override public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { return HomeboxEntity.from(request, response); } @Override public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { // casts to HomeboxEntity and updates token, attachmentToken, expiresAt } @Override public Instant getTokenExpiry(ConnectionEntity entity) { return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null; } } ``` ***ConnectionService.java*** ```java @Service public class ConnectionService { // auto-discovers all ConnectionProvider beans into a serviceType → provider map public LoginResult login(ConnectionRequest request) { provider.checkCredentials(request); ConnectionResponse response = provider.authenticate(request); ConnectionEntity existing = provider.findUniqueConnectionEntry(request); // insert or update, then return LoginResult(connectionId, expiresAt) } public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) { // looks up entity by id, calls provider.getTokenExpiry() to compute connected flag } } ``` ## DTOs ### Request / Internal - **`ConnectionRequest`** — fields: `appUrl`, `serviceType`, `username`, `password`, `apiKey`, `stayLoggedIn`. Typed fields (not a credentials map) let providers validate exactly what they need via `checkCredentials()`. `apiKey` covers future API-key-only providers (e.g. WikiJS). - **`ConnectionResponse`** — internal DTO between provider and service: `token`, `expiresAt`, `Map extraResponseData`. The `getExtraVar(key)` helper safely extracts app-specific values (e.g. Homebox's `attachmentToken`). ### Response (returned to frontend) - **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session. - **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend. - **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`. ## Supported Service Types Registered in the `ServiceType` enum. Currently: `HOMEBOX`. Adding a new service means implementing `ConnectionProvider` and adding the discriminator value — no changes to `ConnectionService` or `ConnectionController` required.