Files
Vaessl/docs/03-Architecture/01-Login-Architecture.md
T

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 (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

@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 via checkCredentials(). apiKey covers future API-key-only providers (e.g. WikiJS).

  • ConnectionResponse — internal DTO between provider and service: token, expiresAt, Map<String, Object> extraResponseData. The getExtraVar(key) helper safely extracts app-specific values (e.g. Homebox's attachmentToken).

Response (returned to frontend)

  • LoginResultconnectionId, expiresAt. Returned by ConnectionService.login() and used by the controller to populate the session.

  • AuthResponseserviceType, expiresAt. Returned by POST /api/login to the frontend.

  • ConnectionStatusResponseserviceType, 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.