Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Combine authentication providers #38

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jpro-auth/core/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
requires one.jpro.platform.internal.openlink;

opens one.jpro.platform.auth.core;
opens one.jpro.platform.auth.core.authentication;

exports one.jpro.platform.auth.core;
exports one.jpro.platform.auth.core.api;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package one.jpro.platform.auth.core.authentication;

import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

/**
* This class allows for the aggregation of multiple authentication providers,
* where the authentication process can be tailored to succeed based on the
* success of either all (AND logic) or any (OR logic) of the included providers.
*
* @author Besmir Beqiri
*/
public class CombineAuthenticationProvider implements AuthenticationProvider<Credentials> {

/**
* Creates a combined authentication provider that will resolve if all
* contained authentication providers are successful. This is equivalent
* to an AND operation among all providers.
*
* @return new instance of CombineAuthenticationProvider set to require all providers to succeed.
*/
public static CombineAuthenticationProvider all() {
return new CombineAuthenticationProvider(true);
}

/**
* Creates a combined authentication provider that will resolve if any
* contained authentication provider is successful. This is equivalent
* to an OR operation among all providers.
*
* @return new instance of CombineAuthenticationProvider set to require any provider to succeed.
*/
public static CombineAuthenticationProvider any() {
return new CombineAuthenticationProvider(false);
}

private final List<AuthenticationProvider<? super Credentials>> providers = new ArrayList<>();
private final boolean all;

private CombineAuthenticationProvider(boolean all) {
this.all = all;
}

/**
* Adds an authentication provider to this combined authentication provider.
* <p>
* This method allows for the dynamic addition of authentication providers
* into the combined provider. The added provider will participate in the
* authentication process according to the logic (all/any) set for this
* combined provider.
* </p>
*
* @param other the authentication provider to add
* @return self-reference for method chaining
*/
public CombineAuthenticationProvider add(AuthenticationProvider<? super Credentials> other) {
providers.add(other);
return this;
}

@Override
public CompletableFuture<User> authenticate(@NotNull Credentials credentials) {
try {
credentials.validate(null);
} catch (CredentialValidationException ex) {
return CompletableFuture.failedFuture(ex);
}

if (providers.isEmpty()) {
return CompletableFuture.failedFuture(
new AuthenticationException("The combined providers list is empty."));
} else {
return iterate(0, credentials, null);
}
}

private CompletableFuture<User> iterate(final int idx, final Credentials credentials, final User previousUser) {
// stop condition
if (idx >= providers.size()) {
if (!all) {
// no more providers, means that we failed to find a provider capable of performing this operation
return CompletableFuture.failedFuture(
new AuthenticationException("No provider capable of performing this operation."));
} else {
// if ALL then a success completes
return CompletableFuture.completedFuture(previousUser);
}
}

// attempt to perform operation
return providers.get(idx)
.authenticate(credentials)
.thenCompose(user -> {
if (!all) {
// if ANY then a success completes
return CompletableFuture.completedFuture(user);
} else {
// if ALL then a success check the next one
return iterate(idx + 1, credentials, previousUser == null ? user : previousUser.merge(user));
}
})
.exceptionallyCompose(err -> {
// try again with next provider
if (!all) {
// try again with next provider
return iterate(idx + 1, credentials, null);
} else {
// short circuit when ALL is used a failure is enough to terminate
// no more providers, means that we failed to find a provider capable of performing this operation
return CompletableFuture.failedFuture(err);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,29 @@ private boolean hasKey(JSONObject json, String key) {
}
return exists;
}

/**
* Merges this user with another user, combining their roles and attributes.
* The name of this user is retained.
*
* @param other the other user to merge with.
* @return a new User instance combining the information of both users.
*/
public User merge(User other) {
// Check if the other user is null, return this user
if (other == null) {
return this;
}

// Merge roles
Set<String> mergedRoles = new HashSet<>(this.roles);
mergedRoles.addAll(other.getRoles());

// Merge attributes
Map<String, Object> mergedAttributes = new HashMap<>(this.attributes);
other.getAttributes().forEach(mergedAttributes::putIfAbsent);

// Create a new User with the combined roles and attributes
return new User(this.name, mergedRoles, mergedAttributes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package one.jpro.platform.auth.core.authentication;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

/**
* Combine authentication providers tests.
*
* @author Besmir Beqiri
*/
public class CombineAuthenticationProviderTests {

private CombineAuthenticationProvider combineAuthProvider;
private AuthenticationProvider<Credentials> mockProvider;
private Credentials mockCredentials;

@BeforeEach
public void setup() {
// Initialize the mock objects
mockProvider = mock(AuthenticationProvider.class);
mockCredentials = mock(Credentials.class);
}

@Test
public void shouldFailCredentialValidation() {
// Setup to simulate a failed credential validation
CredentialValidationException exception = new CredentialValidationException("Invalid credentials");
doThrow(exception).when(mockCredentials).validate(null);

combineAuthProvider = CombineAuthenticationProvider.any(); // 'all' doesn't matter for this test
CompletableFuture<User> result = combineAuthProvider.authenticate(mockCredentials);

// Assert that the future completed exceptionally with the right exception
ExecutionException thrown = assertThrows(ExecutionException.class, result::get);
assertInstanceOf(CredentialValidationException.class, thrown.getCause());
}

@Test
public void shouldFailWhenNoProviders() {
combineAuthProvider = CombineAuthenticationProvider.any(); // 'all' doesn't matter here
CompletableFuture<User> result = combineAuthProvider.authenticate(mockCredentials);

// Assert that the authentication fails due to no providers available
ExecutionException thrown = assertThrows(ExecutionException.class, result::get);
assertInstanceOf(AuthenticationException.class, thrown.getCause());
assertEquals("The combined providers list is empty.", thrown.getCause().getMessage());
}

@Test
public void shouldAuthenticateWithSingleProvider() throws Exception {
User mockUser = mock(User.class);
when(mockProvider.authenticate(mockCredentials)).thenReturn(CompletableFuture.completedFuture(mockUser));

combineAuthProvider = CombineAuthenticationProvider.any(); // With 'all' set to false, one success is enough
combineAuthProvider.add(mockProvider);
CompletableFuture<User> result = combineAuthProvider.authenticate(mockCredentials);

// Assert successful authentication with the mock user
assertEquals(mockUser, result.get());
}

@Test
public void shouldFailWithAllProvidersWhenAnyIsRequired() {
// Setup to simulate authentication failure
AuthenticationException exception = new AuthenticationException("Authentication failed");
when(mockProvider.authenticate(mockCredentials)).thenReturn(CompletableFuture.failedFuture(exception));

combineAuthProvider = CombineAuthenticationProvider.any(); // 'all' set to true, requiring all to succeed
combineAuthProvider.add(mockProvider);
CompletableFuture<User> result = combineAuthProvider.authenticate(mockCredentials);

// Assert that the authentication fails appropriately
ExecutionException thrown = assertThrows(ExecutionException.class, result::get);
assertInstanceOf(AuthenticationException.class, thrown.getCause());
assertEquals("No provider capable of performing this operation.", thrown.getCause().getMessage());
}

@Test
public void shouldSucceedWithAllProvidersWhenAllIsRequired() throws Exception {
// Setup to simulate successful authentication with multiple providers
User mockUser1 = mock(User.class);
User mockUser2 = mock(User.class);
User mergedUser = mock(User.class);

AuthenticationProvider<Credentials> secondMockProvider = mock(AuthenticationProvider.class);
when(mockProvider.authenticate(mockCredentials)).thenReturn(CompletableFuture.completedFuture(mockUser1));
when(secondMockProvider.authenticate(mockCredentials)).thenReturn(CompletableFuture.completedFuture(mockUser2));
when(mockUser1.merge(mockUser2)).thenReturn(mergedUser);

combineAuthProvider = CombineAuthenticationProvider.all(); // 'all' set to true, requiring all to succeed
combineAuthProvider.add(mockProvider).add(secondMockProvider);
CompletableFuture<User> result = combineAuthProvider.authenticate(mockCredentials);

// Assert successful authentication with the merged user
assertEquals(mergedUser, result.get());
}
}
Loading