Skip to content
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
30 changes: 30 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
<reproducible-build-maven-plugin.version>0.16</reproducible-build-maven-plugin.version>
<maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version>
<auto-service.version>1.0.1</auto-service.version>
<junit-jupiter.version>5.10.2</junit-jupiter.version>
<mockito-core.version>5.12.0</mockito-core.version>
<uap-java.version>1.6.1</uap-java.version>
</properties>

<dependencies>
Expand All @@ -39,6 +42,33 @@
<version>${auto-service.version}</version>
<scope>provided</scope>
</dependency>

<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
<version>${junit-jupiter.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
<version>${junit-jupiter.version}</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito-core.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.github.ua-parser</groupId>
<artifactId>uap-java</artifactId>
<scope>provided</scope>
<version>${uap-java.version}</version>
</dependency>
</dependencies>

<dependencyManagement>
Expand Down
1 change: 1 addition & 0 deletions spi/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
</dependencies>

<build>
<testSourceDirectory>src/test/java</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,13 @@

import static nl.wouterh.keycloak.trusteddevice.authenticator.RegisterTrustedDeviceAuthenticatorFactory.CONF_DURATION;

import com.google.common.base.Strings;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialModel;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProvider;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProviderFactory;
import nl.wouterh.keycloak.trusteddevice.util.TrustedDeviceToken;
import nl.wouterh.keycloak.trusteddevice.util.UserAgentParser;

import org.apache.commons.codec.binary.Hex;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
Expand All @@ -27,6 +20,16 @@
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import com.google.common.base.Strings;

import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialModel;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProvider;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProviderFactory;
import nl.wouterh.keycloak.trusteddevice.util.TrustedDeviceToken;
import nl.wouterh.keycloak.trusteddevice.util.UserAgentParser;

public class RegisterTrustedDeviceAuthenticator implements Authenticator {

private static final SecureRandom secureRandom = new SecureRandom();
Expand All @@ -47,9 +50,11 @@ public void authenticate(AuthenticationFlowContext context) {
TrustedDeviceCredentialModel credential = TrustedDeviceToken.getCredentialFromCookie(
context.getSession(), realm, user);

// Check if the user already has a trusted device
if (credential != null) {
context.success();
} else {
// Otherwise, show the registration form
Response form = context.form()
.setAttribute("trustedDeviceName", UserAgentParser.getDeviceName(session))
.createForm("trusted-device-register.ftl");
Expand Down Expand Up @@ -93,7 +98,7 @@ public void action(AuthenticationFlowContext context) {
secureRandom.nextBytes(bytes);
String deviceId = Hex.encodeHexString(bytes);

// Expire the token in 1 year
// Expire the token in configured duration
Long exp = null;
String credentialName = deviceName;
if (duration != null) {
Expand All @@ -106,6 +111,7 @@ public void action(AuthenticationFlowContext context) {
TrustedDeviceCredentialModel trustedDeviceCredentialModel = TrustedDeviceCredentialModel.create(
credentialName, deviceId, exp);

// Remove all expired credentials
trustedDeviceCredentialProvider.removeExpiredCredentials(realm, user);

// Add the new credential
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
package nl.wouterh.keycloak.trusteddevice.util;

import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.NewCookie.SameSite;
import jakarta.ws.rs.core.Cookie;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialModel;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProvider;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProviderFactory;
import org.keycloak.TokenCategory;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time;
Expand All @@ -19,6 +9,17 @@
import org.keycloak.models.UserModel;
import org.keycloak.representations.JsonWebToken;

import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.NewCookie.SameSite;
import jakarta.ws.rs.core.UriBuilder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialModel;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProvider;
import nl.wouterh.keycloak.trusteddevice.credential.TrustedDeviceCredentialProviderFactory;

@Getter
@Setter
@NoArgsConstructor
Expand Down Expand Up @@ -50,7 +51,13 @@ private static void addCookie(KeycloakSession session, RealmModel realm, String
session.getContext().getHttpResponse().setCookieIfAbsent(newCookie);
}

public static TrustedDeviceToken getCookie(KeycloakSession session) {
/**
* Retrieves the TrustedDeviceToken from the session cookie.
*
* @param session The KeycloakSession object.
* @return The TrustedDeviceToken object if the cookie exists and is valid, otherwise null.
*/
private static TrustedDeviceToken getCookie(KeycloakSession session) {
Cookie cookie = session.getContext().getRequestHeaders().getCookies().get(COOKIE_NAME);
long time = Time.currentTime();

Expand All @@ -66,8 +73,17 @@ public static TrustedDeviceToken getCookie(KeycloakSession session) {
return null;
}

/**
* Retrieves the trusted device credential from the cookie.
*
* @param session the Keycloak session
* @param realm the realm model
* @param user the user model
* @return the trusted device credential, or null if not found or invalid
*/
public static TrustedDeviceCredentialModel getCredentialFromCookie(KeycloakSession session,
RealmModel realm, UserModel user) {

TrustedDeviceToken deviceToken = getCookie(session);
TrustedDeviceCredentialProvider trustedDeviceCredentialProvider = (TrustedDeviceCredentialProvider) session
.getProvider(CredentialProvider.class, TrustedDeviceCredentialProviderFactory.PROVIDER_ID);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package nl.wouterh.keycloak.trusteddevice;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.keycloak.TokenCategory;
import org.keycloak.common.util.Time;

import nl.wouterh.keycloak.trusteddevice.util.TrustedDeviceToken;

public class TrustedDeviceTokenTest {

@Test
public void testCreateTrustedDeviceToken() {
TrustedDeviceToken token = new TrustedDeviceToken("secret-id", "secret", (long) 10000000);

// Check mappers work correctly
assertEquals("secret-id", token.getId());
assertEquals("secret", token.getSecret());
assertEquals(10000000, token.getExp());
assertTrue(token.getIat() >= Time.currentTime(),
"iat should be set to current time or later to avoid time drift issues in test");

assertEquals(TokenCategory.INTERNAL, token.getCategory());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package nl.wouterh.keycloak.trusteddevice.util;

import org.junit.jupiter.api.Test;

import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class UserAgentParserTest {

@Test
public void testGetDeviceName() {

// Mock the KeycloakSession
KeycloakSession session = mock(KeycloakSession.class);
HttpHeaders headers = mock(HttpHeaders.class);
KeycloakContext context = mock(KeycloakContext.class);

// Mock the KeycloakSession to return the mocked HttpHeaders
when(session.getContext()).thenReturn(context);
when(session.getContext().getRequestHeaders()).thenReturn(headers);

// Mock the User-Agent header
when(headers.getHeaderString(HttpHeaders.USER_AGENT))
.thenReturn("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3");

// Call the method under test
String deviceName = UserAgentParser.getDeviceName(session);

// Verify the result
assertEquals("Chrome on Windows", deviceName);
}
}