7.1 KiB
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
@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
@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
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 (throwsEmptyCredentialsExceptionwith a list of missing fields)findUniqueConnectionEntry— looks up an existing record to decide insert vs. updategetTokenExpiry— default returnsnull(no expiry); token-based providers override this soConnectionServicecan compute theconnectedflag
HomeboxConnectionProvider.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
@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 viacheckCredentials().apiKeycovers future API-key-only providers (e.g. WikiJS). -
ConnectionResponse— internal DTO between provider and service:token,expiresAt,Map<String, Object> extraResponseData. ThegetExtraVar(key)helper safely extracts app-specific values (e.g. Homebox'sattachmentToken).
Response (returned to frontend)
-
LoginResult—connectionId,expiresAt. Returned byConnectionService.login()and used by the controller to populate the session. -
AuthResponse—serviceType,expiresAt. Returned byPOST /api/loginto the frontend. -
ConnectionStatusResponse—serviceType,appUrl,username,expiresAt,connected. Returned byGET /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.