**vaessl: Login Architecture** # Backend The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods. ## Single Table Inheritance The database entities will follow the Single Table Inheritance concept. The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy: ***ConnectionEntitiy.java*** ``` package com.vaessl.app.connection; import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity @Table(name = "connections") @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "service_type") @Getter @Setter @NoArgsConstructor public abstract class ConnectionEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; private String appUrl; } ``` ***HomeboxEntitiy.java*** ``` package com.vaessl.app.connection; import java.time.Instant; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import lombok.Getter; import lombok.Setter; @Entity @DiscriminatorValue("HOMEBOX") @Getter @Setter 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.setToken(response.token()); he.setAttachmentToken(response.getExtraVar("attachmentToken")); he.setExpiresAt(response.expiresAt()); return he; } } ``` ## The Provider Pattern (Logic Layer) To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General). - ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity. ***ConnectionProvider.java*** ``` package com.vaessl.app.connection; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; public interface ConnectionProvider { String getServiceType(); ConnectionResponse authenticate(ConnectionRequest request); ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response); } ``` ***HomeboxConnectionProvider.java*** ``` package com.vaessl.app.connection; import java.time.Instant; import java.util.HashMap; import java.util.Map; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; import static com.vaessl.app.connection.Endpoint.*; @Component public class HomeBoxConnectionProvider implements ConnectionProvider { private final RestClient.Builder restClientBuilder; private final ConnectionRepository cRepository; public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { this.restClientBuilder = restClientBuilder; this.cRepository = cRepository; } @Override public String getServiceType() { return "HOMEBOX"; } @Override public ConnectionResponse authenticate(ConnectionRequest request) { Map homeboxPayload = Map.of("username", request.credentials().get("username"), "password", request.credentials().get("password"), "stayLoggedIn", request.stayLoggedIn()); HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) .build() .post() .uri(HOMEBOX_LOGIN.getValue()) .body(homeboxPayload) .retrieve() .body(HomeboxLoginResponse.class); if (hbResponse == null) { throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); } Map attachmentToken = new HashMap<>(); attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); } @Override public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { return HomeboxEntity.from(request, response); } @Override public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { if (existing instanceof HomeboxEntity hbE) { hbE.setToken(response.token()); hbE.setExpiresAt(response.expiresAt()); hbE.setAttachmentToken(response.getExtraVar("attachmentToken")); cRepository.save(hbE); } } private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { } } ``` - Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string. ***ConnectionService.java*** ``` package com.vaessl.app.connection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.stereotype.Service; import com.vaessl.app.dto.ConnectionRequest; import com.vaessl.app.dto.ConnectionResponse; import com.vaessl.app.exception.ProviderNotFoundException; @Service public class ConnectionService { private final Map providerRegistry; private final ConnectionRepository cRepository; public ConnectionService(List providers, ConnectionRepository cRepository) { this.providerRegistry = providers.stream() .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); this.cRepository = cRepository; } public ConnectionResponse login(ConnectionRequest request) { ConnectionProvider provider = providerRegistry.get(request.serviceType()); if (provider == null) { throw new ProviderNotFoundException(); } ConnectionResponse response = provider.authenticate(request); ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); if (existing != null) { provider.updateToRepository(existing, response); } else { ConnectionEntity newEntity = provider.connectionToEntity(request, response); cRepository.save(newEntity); } return response; } } ``` ## Generic Data Exchange (The DTOs) Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database. - ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map for credentials. This allows one DTO to handle both username/password logins and API-key-only logins. ***ConnectionRequest.java*** ``` package com.vaessl.app.dto; import java.util.Map; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; public record ConnectionRequest( @NotBlank(message = "App URL is mandatory") String appUrl, @NotBlank(message = "Service type is mandatory") String serviceType, @NotEmpty(message = "Credentials are mandatory") Map credentials, @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { public ConnectionRequest { if (stayLoggedIn == null) { stayLoggedIn = false; } } } ``` - ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map called extraResponseData. - The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need. ***ConnectionResponse.java*** ``` package com.vaessl.app.dto; import java.time.Instant; import java.util.Map; public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { public String getExtraVar(String key) { if(extraResponseData == null) { return null; } else { Object value = extraResponseData.get(key); return value != null ? String.valueOf(value) : null; } } } ```