Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
59bc7e8
feat: add a conditional geo information authenticator (country and co…
mathieu-claudel-kns Apr 9, 2026
63f828b
Update softprops/action-gh-release action to v3
renovate[bot] Apr 12, 2026
fa6cdc8
Update actions/upload-pages-artifact action to v5
renovate[bot] Apr 13, 2026
ed81e3e
Update quay.io/keycloak/keycloak Docker tag to v26.6.1
renovate[bot] Apr 16, 2026
24aa548
Update version.keycloak to v26.6.1
renovate[bot] Apr 16, 2026
1d668c4
Update dependency ruby to v4.0.3
renovate[bot] Apr 21, 2026
663fc2e
Update dependency org.projectlombok:lombok to v1.18.46
renovate[bot] Apr 22, 2026
ac29090
Fix bugs and align location condition with project/Keycloak conventions
BenLad17 Apr 26, 2026
0810186
Merge renovate/version.lombok into release/next
BenLad17 Apr 26, 2026
b86e8fd
Merge renovate/ruby-4.x into release/next
BenLad17 Apr 26, 2026
8b0ddab
Merge renovate/actions-upload-pages-artifact-5.x into release/next
BenLad17 Apr 26, 2026
e5b6e6c
Merge renovate/softprops-action-gh-release-3.x into release/next
BenLad17 Apr 26, 2026
d5b8f14
Merge renovate/quay.io-keycloak-keycloak-26.x into release/next
BenLad17 Apr 26, 2026
23560d5
Merge renovate/version.keycloak into release/next
BenLad17 Apr 26, 2026
6b04e48
Merge fix/location-condition into release/next
BenLad17 Apr 26, 2026
bcac0ef
Add Keycloak 26.6.1 to test matrix
BenLad17 Apr 26, 2026
a3629c3
Replace removed Platform.getPlatform().getTmpDirectory() for Keycloak…
BenLad17 Apr 26, 2026
c6628d8
fix: rename snake_case variables to camelCase in LocationConditionalA…
BenLad17 Apr 26, 2026
058bad5
ci: add Keycloak 26.6.0 to test matrix
BenLad17 Apr 26, 2026
bb74fba
fix: use Keycloak Environment.getDataDir() for temp directory in maxm…
BenLad17 Apr 26, 2026
b433976
fix: use kc.io.tmpdir system property for temp directory, matching Ke…
BenLad17 Apr 26, 2026
20a0c93
fix: use KeycloakApplication.getTmpDirectory() for temp directory
BenLad17 Apr 26, 2026
c889c2b
docs: add release notes for v1.1.0
BenLad17 Apr 26, 2026
50c1f3f
fix: use kc.io.tmpdir system property for cross-version Keycloak comp…
BenLad17 Apr 26, 2026
f2a005b
fix: use KeycloakApplication.getTmpDirectory() (requires Keycloak 26.6+)
BenLad17 Apr 26, 2026
a436205
ci: drop Keycloak 26.5.x from test matrix, minimum is now 26.6
BenLad17 Apr 26, 2026
fedba3d
docs: update v1.1.0 release notes to reflect Keycloak 26.6+ requirement
BenLad17 Apr 26, 2026
259adbf
fix: wait for async realm deletion before re-creating in test (Keyclo…
BenLad17 Apr 26, 2026
1cceafa
fix: handle InterruptedException in realm deletion wait loop
BenLad17 Apr 26, 2026
16f8bb5
Fix async realm deletion race in testLoginHistoryIsDeletedOnRealmDele…
BenLad17 Apr 26, 2026
92cee69
Use throwaway realm in testLoginHistoryIsDeletedOnRealmDeletion
BenLad17 Apr 27, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/all_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
keycloak_version: [ 26.5.0 , 26.5.1, 26.5.2, 26.5.3, 26.5.4, 26.5.5, 26.5.6, 26.5.7 ]
keycloak_version: [ 26.6.0, 26.6.1 ]
continue-on-error: false
steps:
- uses: actions/checkout@v6
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/deploy_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0.2'
ruby-version: '4.0.3'
bundler-cache: true
cache-version: 0 # Increment this number if you need to re-download cached gems
working-directory: 'docs'
Expand All @@ -44,7 +44,7 @@ jobs:
env:
JEKYLL_ENV: production
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: 'docs/_site/'

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
run: mv provider/target/keycloak-geoaware-shaded.jar provider/target/keycloak-geoaware.jar

- name: GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: provider/target/keycloak-geoaware.jar
fail_on_unmatched_files: true
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:
keycloak:
container_name: keycloak
hostname: keycloak
image: quay.io/keycloak/keycloak:26.5.7
image: quay.io/keycloak/keycloak:26.6.1
depends_on:
- postgres
environment:
Expand Down Expand Up @@ -60,7 +60,7 @@ services:

export-keycloak-realm:
container_name: keycloak-export
image: quay.io/keycloak/keycloak:26.5.7
image: quay.io/keycloak/keycloak:26.6.1
profiles:
- export-realm
depends_on:
Expand All @@ -76,7 +76,7 @@ services:

import-keycloak-realm:
container_name: keycloak-import
image: quay.io/keycloak/keycloak:26.5.7
image: quay.io/keycloak/keycloak:26.6.1
profiles:
- import-realm
depends_on:
Expand Down
38 changes: 38 additions & 0 deletions docs/release-notes/v1.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: Version 1.1.0
layout: default
parent: Release Notes
---

# Version 1.1.0

## Requirements

- Keycloak 26.6 or later

## New Features

### Location Condition Authenticator

A new [conditional authenticator](https://www.keycloak.org/docs/latest/server_admin/#conditions) that gates authentication flows based on the geographic location of the client's IP address.

Configure it in any Keycloak authentication flow under **Condition - GeoAware Location**.

**Match by:**

- Country ISO code (e.g. `DE`, `US`)
- Country name (e.g. `Germany`)
- Continent name (e.g. `Europe`)

**Options:**

- Configurable list of allowed values (multi-value)
- **Inverse decision**: revert the match result to block instead of allow, or vice versa

When no GeoIP provider is available for the client's IP, the condition evaluates to `false` (fails closed).

## Changes since 1.0.0

- Added Location Condition authenticator
- Fixed `Platform.getTmpDirectory()` removal in Keycloak 26.6: MaxMind autodownload now uses `KeycloakApplication.getTmpDirectory()`
- Extended test matrix to cover Keycloak 26.6.0 and 26.6.1
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.b2code.loginhistory;

import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
import org.b2code.base.BaseTest;
import org.b2code.config.MaxmindGeoLiteFileServerConfig;
Expand All @@ -10,6 +11,11 @@
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.util.ApiUtil;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.util.Map;
import java.util.UUID;

@KeycloakIntegrationTest(config = MaxmindGeoLiteFileServerConfig.class)
Expand All @@ -29,20 +35,65 @@ void testLoginHistoryIsDeletedOnAccountDeletion() {
}

@Test
void testLoginHistoryIsDeletedOnRealmDeletion() {
login();

int loginRecordsBeforeDeletion = getLoginRecords().size();
Assertions.assertEquals(1, loginRecordsBeforeDeletion, "Exactly one login history record is expected after initial login");
void testLoginHistoryIsDeletedOnRealmDeletion() throws Exception {
// Use a throwaway realm so the framework-managed realm stays intact.
// Deleting the framework realm causes OAuthClient.close() to fail in afterEach
// (the re-created realm lacks the test OAuth client), aborting the destroy loop
// and cascading 409s to every subsequent test's beforeEach.
String tempRealmName = "temp-realm-" + UUID.randomUUID();
RealmRepresentation tempRealmRep = new RealmRepresentation();
tempRealmRep.setRealm(tempRealmName);
tempRealmRep.setEnabled(true);
adminClient.realms().create(tempRealmRep);

// Create a real user in the temp realm — required because deleteByRealmId
// queries UserEntity.realmId to find which records to delete.
String tempUserId;
UserRepresentation tempUser = new UserRepresentation();
tempUser.setUsername("temp-user-" + UUID.randomUUID());
tempUser.setEnabled(true);
try (Response resp = adminClient.realm(tempRealmName).users().create(tempUser)) {
Assertions.assertEquals(201, resp.getStatus(), "Temp user must be created successfully");
tempUserId = ApiUtil.getCreatedId(resp);
}

RealmRepresentation realmRep = realm.getCreatedRepresentation();
realm.admin().remove();
// Insert a login record directly — the plugin uses event listeners for deletion,
// so a directly-inserted record still exercises the cascade correctly.
Map<String, String> dbConfig = testDatabase.serverConfig();
try (Connection conn = DriverManager.getConnection(
dbConfig.get("db-url"), dbConfig.get("db-username"), dbConfig.get("db-password"));
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO geoaware_login_record (ID, USER_ID, TIMESTAMP, IP_ADDRESS) VALUES (?, ?, ?, ?)")) {
ps.setString(1, UUID.randomUUID().toString());
ps.setString(2, tempUserId);
ps.setTimestamp(3, new Timestamp(System.currentTimeMillis()));
ps.setString(4, "127.0.0.1");
ps.executeUpdate();
}

int loginRecordsAfterDeletion = getLoginRecords().size();
Assertions.assertEquals(0, loginRecordsAfterDeletion, "Login history records must be deleted after realm deletion");
Assertions.assertEquals(1, loginHistory.getAllByUserId(tempUserId).size(),
"Login record must exist before realm deletion");

adminClient.realm(tempRealmName).remove();

// Wait for deletion — Keycloak 26.6+ deletes asynchronously.
long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) {
try {
adminClient.realm(tempRealmName).toRepresentation();
} catch (NotFoundException e) {
break;
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}

// Re-create the realm to not confuse the test framework
adminClient.realms().create(realmRep);
Assertions.assertEquals(0, loginHistory.getAllByUserId(tempUserId).size(),
"Login history records must be deleted after realm deletion");
}

@Test
Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<version.keycloak>26.5.7</version.keycloak>
<version.keycloak>26.6.1</version.keycloak>
<version.geoip2>5.0.2</version.geoip2>
<version.ipinfo>3.4.0</version.ipinfo>
<version.lombok>1.18.44</version.lombok>
<version.lombok>1.18.46</version.lombok>
<version.auto-service>1.1.1</version.auto-service>
<version.validation-api>3.1.1</version.validation-api>
<version.apache-commons-compress>1.28.0</version.apache-commons-compress>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package org.b2code.authentication.ip;

import com.google.auto.service.AutoService;
import org.b2code.PluginConstants;
import org.b2code.authentication.ip.condition.LocationCondition;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;

import java.util.List;

/**
* Factory for {@link LocationCondition}, registered as a Keycloak conditional authenticator.
* Provides configuration properties for location type (country, country ISO code, continent),
* match values, and an option to invert the decision.
*/
@AutoService(AuthenticatorFactory.class)
public class LocationConditionalAuthenticatorFactory implements ConditionalAuthenticatorFactory {

public static final String PROVIDER_ID = PluginConstants.PLUGIN_NAME_LOWER_CASE + "-condition-location";


public static final String COUNTRY="Country name";
public static final String COUNTRY_ISO_CODE="Country Iso Code";
public static final String CONTINENT="Continent name";

public static final String CONFIG_VALUE_TYPE="value-type";
public static final String CONFIG_VALUES="values";
public static final String CONFIG_REVERT="revert";

private static final List<String> CONDITION_OPTIONS = List.of(COUNTRY_ISO_CODE, COUNTRY, CONTINENT);


@Override
public void init(Config.Scope config) {

}

@Override
public void postInit(KeycloakSessionFactory factory) {

}

@Override
public void close() {

}

@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public String getDisplayType() {
return "Condition - " + PluginConstants.PLUGIN_NAME + " Location";
}

@Override
public String getHelpText() {
return LocationCondition.instance().getHelpText();
}


@Override
public boolean isConfigurable() {
return true;
}

private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
};

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES.clone();
}

@Override
public boolean isUserSetupAllowed() {
return false;
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {

ProviderConfigProperty valuesType = new ProviderConfigProperty();
valuesType.setName(CONFIG_VALUE_TYPE);
valuesType.setLabel("Values type");
valuesType.setType(ProviderConfigProperty.LIST_TYPE);
valuesType.setRequired(true);
valuesType.setDefaultValue(COUNTRY_ISO_CODE);
valuesType.setHelpText("Select the type of value.");
valuesType.setOptions(CONDITION_OPTIONS);


ProviderConfigProperty values = new ProviderConfigProperty();
values.setName(CONFIG_VALUES);
values.setLabel("Values");
values.setType(ProviderConfigProperty.MULTIVALUED_STRING_TYPE);
values.setDefaultValue("");
values.setHelpText("List of the match values");

ProviderConfigProperty revertDecision = new ProviderConfigProperty();
revertDecision.setName(CONFIG_REVERT);
revertDecision.setLabel("Inverse decision");
revertDecision.setType(ProviderConfigProperty.BOOLEAN_TYPE);
revertDecision.setDefaultValue(false);
revertDecision.setHelpText("Revert the condition decision");

return ProviderConfigurationBuilder.create()
.property(valuesType)
.property(values)
.property(revertDecision)
.build();
}

@Override
public ConditionalAuthenticator getSingleton() {
return LocationCondition.SINGLETON;
}
}
Loading
Loading