01Lab Metadata
| Field | Value |
|---|---|
| Lab ID | IB-SIA-08 |
| Track | Identity Bytes, Senior IAM Architect Track |
| Phase | Phase 1, Core Identity Operations |
| Difficulty | Advanced |
| Estimated Time | 5 to 6.5 hours |
| Core Technologies | Java 17, Keycloak SPI (Authenticator interface), Maven 3.9, Python 3.11, Flask 3.0, Redis 7.2, Keycloak 24.x (HA cluster), Docker |
| Builds On | Lab 01 (OpenLDAP directory), Lab 02 (Keycloak realm and browser flow), Lab 05 (MFA and WebAuthn, used as the step-up target), Lab 06 (Keycloak High Availability cluster, deployment target for the custom provider) |
| Feeds Into | Lab 09 (JWT/JWS/JWE deep dive), Lab 32 (Threat modelling), Lab 33 (Identity incident response) |
02Lab Title and Description
Adaptive and Risk-Based Authentication
Northgate Financial's security operations centre flagged a pattern last quarter: a finance-team credential was used to authenticate successfully from a UK office IP address at 09:14, and again from an IP address geolocated to Lagos, Nigeria at 09:26. Twelve minutes is not enough time to travel that distance. The login succeeded because Keycloak's browser flow, as configured through Lab 05, treats every correct password and every enrolled second factor as equally trustworthy regardless of where or how it was presented. There was no mechanism to say "this specific login looks wrong even though the credentials are correct."
In this lab you close that gap by writing a custom Keycloak Authenticator using the Service Provider Interface (SPI), the extension mechanism Keycloak exposes for adding logic into its authentication flows. Your authenticator calls a purpose-built risk scoring microservice on every login attempt, evaluating impossible travel velocity between the current and previous successful login, whether the combination of IP address and browser fingerprint has been seen for this user before, and whether the source IP falls in a known-bad range. Based on the returned score, the authenticator allows the login through unchanged, forces the step-up one-time passcode flow you built in Lab 05, or denies the attempt outright and raises a Keycloak admin event for the security operations centre to review in Lab 33.
This is deliberately the most involved lab in Phase 1. Writing and packaging a Java SPI, then deploying it consistently across both nodes of the Lab 06 HA cluster without downtime, is representative of the kind of platform engineering a Senior IAM Architect is expected to own, not only configure through a web console.
Estimated completion time: 5 to 6.5 hours, including Java SPI development, deployment across the HA cluster, and verification of all three risk scenarios.
03Prerequisites
Completed Prior Labs
| Lab | Why it is required |
|---|---|
| IB-SIA-01, OpenLDAP directory | Supplies the user records (asmith, jpatel, lokafor) whose login events this lab scores for risk. |
| IB-SIA-02, Keycloak realm and browser flow | The custom authenticator you build in this lab is inserted into the existing northgate realm's browser authentication flow, immediately after password validation. |
| IB-SIA-05, MFA and WebAuthn | Provides the OTP and WebAuthn step-up execution that this lab's authenticator conditionally forces for medium-risk logins. Without Lab 05's flow, there is no step-up target to redirect to. |
| IB-SIA-06, Keycloak High Availability | Custom SPI providers must be deployed identically to both ib-keycloak-1 and ib-keycloak-2, and the deployment process must not take the cluster fully offline. This lab is where the operational cost of the HA architecture becomes concrete. |
System Requirements
| Resource | Minimum |
|---|---|
| OS | Ubuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2 |
| RAM | 10 GB free (the Keycloak HA cluster, Redis and the risk engine together use roughly 3.2 GB) |
| Disk | 6 GB free (Maven downloads a local dependency cache on first build) |
| CPU | 4 cores recommended; Maven builds are noticeably slower on 2 cores |
| Network | Outbound HTTPS access to Maven Central and Docker Hub |
Required Tools
| Tool | Exact Version |
|---|---|
| Docker Engine | 25.0 or later |
| Docker Compose | v2.24 or later |
| Java Development Kit | 17.0.10 (Temurin distribution recommended; Keycloak 24.x requires Java 17) |
| Apache Maven | 3.9.6 |
| Python | 3.11.x |
| curl | 8.x |
| jq | 1.7 |
Install and verify: Ubuntu/Debian
sudo apt update
sudo apt install -y openjdk-17-jdk maven python3.11 python3.11-venv jq curl
# Verify
java -version # Expect: openjdk version "17.0.x"
mvn -version # Expect: Apache Maven 3.9.x, Java version: 17.0.x
python3.11 --version # Expect: Python 3.11.x
jq --version # Expect: jq-1.7 or later
Install and verify: macOS
brew install openjdk@17 maven python@3.11 jq
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk \
/Library/Java/JavaVirtualMachines/openjdk-17.jdk
java -version
mvn -version
python3.11 --version
jq --version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04
# Then follow the Ubuntu/Debian instructions above inside the WSL2 shell.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get realms/northgate > northgate-realm-backup-pre-lab08.json. If a flow misconfiguration locks you out of the admin console, you can restore from this export.
04Real World Problem Statement
Static authentication treats every successful password and OTP as equally trustworthy. It cannot distinguish a genuine login by Amina Smith from her usual office in Manchester from the same credential being replayed from an unfamiliar network on the other side of the world twelve minutes later. Risk-based authentication adds a layer of contextual judgement that static credential checking cannot provide on its own.
Risk
Credential theft and replay are common regardless of how strong the password policy is. A stolen credential used from an unfamiliar location is the single strongest signal available to a login system that something is wrong, yet Northgate's current flow does not evaluate it at all.
Compliance
The FCA's guidance on operational resilience and the PSD2 Regulatory Technical Standards on Strong Customer Authentication both expect firms handling financial data to apply transaction and session risk analysis, not solely static multi-factor checks, where the sensitivity of the access justifies it.
Productivity
Forcing every single login through step-up MFA regardless of context frustrates staff who log in from the same office and device every day. Risk-based authentication allows Northgate to reserve step-up friction for logins that actually warrant it, improving the experience for the overwhelming majority of low-risk logins.
Security Posture
A risk engine that fails closed, denying or challenging when it cannot compute a score, rather than failing open and allowing every login through, materially raises the bar for an attacker holding a valid but stolen credential.
Concrete scenario: Northgate Financial's security operations centre observed the asmith credential authenticate from a Manchester office IP at 09:14 and from a Lagos-geolocated IP at 09:26 on the same day, a physically impossible journey in twelve minutes. In this lab you reproduce that exact scenario against your own lab environment using curl requests that simulate logins from different source IPs, and confirm your risk engine flags the second login and forces step-up authentication before Keycloak issues a token.
05Skills Mapped to Production Solutions
| Skill Learned | Real-World Enterprise Application |
|---|---|
Writing a custom Keycloak Authenticator using the SPI's org.keycloak.authentication.Authenticator interface | Extending commercial IAM platforms (Keycloak, ForgeRock, PingFederate) beyond their out-of-the-box authentication policy engine |
| Designing a rule-based risk scoring model (impossible travel, new device, IP reputation) | The foundational logic behind commercial risk engines such as Microsoft Entra ID Protection, Okta ThreatInsight and Transmit Security |
| Packaging and deploying custom providers consistently across a multi-node Keycloak cluster | Platform engineering discipline required for any production IAM deployment running more than one node |
| Implementing conditional step-up multi-factor authentication driven by a runtime signal rather than a static policy | Adaptive access policy design in enterprise IGA and access management platforms |
| Designing fail-closed behaviour for a security control dependency | A core resilience principle assessed in IAM architecture reviews and penetration tests: what happens when the risk engine itself is unavailable |
| Raising structured security events from custom authentication logic into Keycloak's admin event log | Feeding identity telemetry into a SIEM for detection engineering, directly relevant to Lab 33's incident response work |
06Architecture Overview
Component Breakdown
| Component | Purpose | Technology | Deployment | Ports | Key Configuration |
|---|---|---|---|---|---|
| RiskBasedAuthenticator | Executes inside Keycloak's browser flow immediately after password validation, calling the risk engine and deciding the flow outcome | Java 17, Keycloak SPI (org.keycloak.authentication.Authenticator) | Compiled JAR copied to /opt/keycloak/providers on both ib-keycloak-1 and ib-keycloak-2 | N/A, runs in-process | Realm attribute risk-engine-url configured through the Admin Console execution config |
| Risk engine | Computes a 0 to 100 risk score from impossible travel, new device fingerprint and IP reputation signals | Python 3.11, Flask 3.0 | Docker container ib-risk-engine on ib-lab-net | 8090 | Static demo GeoIP mapping and IP reputation list, documented in Section 9 |
| Redis | Stores each user's last successful login (timestamp, IP, coarse location, device fingerprint) with a 60 minute rolling window | Redis 7.2 | Docker container ib-redis on ib-lab-net | 6379 (internal only) | No persistence required; login history is intentionally short-lived for this lab |
| Keycloak HA cluster | Hosts the modified browser flow and issues tokens once the authenticator's decision is satisfied | Keycloak 24.x | Existing ib-keycloak-1/-2 from Lab 06, behind ib-lb | 8443 (via ib-lb) | Browser flow duplicated and extended with the new execution |
Data Flow
- The browser submits credentials to Keycloak through the load balancer. Whichever node,
ib-keycloak-1orib-keycloak-2, receives the request runs the same browser flow, since both were built with the identical provider JAR.
Why: the HA cluster from Lab 06 must behave identically regardless of which node serves a given request; a risk decision that differs by node would be a serious correctness bug. - After password validation succeeds, the RiskBasedAuthenticator calls the risk engine over HTTP, passing the source IP, User-Agent string and username.
Why: scoring happens after credentials are confirmed correct, not before, so an attacker cannot use response timing differences to enumerate valid usernames. - The risk engine reads the user's last successful login from Redis, computes impossible travel velocity, checks whether the IP and device combination is new, and checks the source IP against a reputation list, then writes the current attempt back to Redis and returns a score.
Why: keeping login history in fast, short-lived storage rather than the LDAP directory avoids adding write load to the system of record for data that only matters for the next hour. - The authenticator applies the decision: low risk continues the flow unchanged, medium risk redirects into the Lab 05 OTP execution, high risk denies the login and raises a Keycloak admin event.
Why: a single numeric score is not useful to a security analyst on its own; the admin event carries the contributing factors so Lab 33's incident response work has something concrete to investigate.
Security Considerations
| Concern | Lab Approach |
|---|---|
| Encryption | Authenticator to risk engine traffic runs over plain HTTP on the isolated ib-lab-net Docker network. Section 9 documents the production requirement for mutual TLS on this hop. |
| Fail-closed behaviour | If the risk engine does not respond within a two second timeout, the authenticator treats this as a high-risk outcome and forces step-up MFA rather than allowing the login through. |
| Authentication | The authenticator calls the risk engine with a shared bearer token, distinct from any user credential, configured as a realm attribute rather than hard-coded in the JAR. |
| Audit | Every medium and high risk decision raises a custom Keycloak admin event with the contributing risk factors, independent of the risk engine's own application logs. |
07Step by Step Implementation
Phase 1: Build the Risk Engine
Step 1.1: Scaffold the risk engine and its login history store
Project scaffold and dependencies
mkdir -p ~/ib-labs/ib-risk-engine
cd ~/ib-labs/ib-risk-engine
python3.11 -m venv .venv
source .venv/bin/activate
cat > requirements.txt <<'EOF'
flask==3.0.3
redis==5.0.4
gunicorn==22.0.0
EOF
pip install -r requirements.txt
pip list and confirm flask, redis and gunicorn appear with the pinned versions above. If redis fails to install, confirm you activated the virtual environment with source .venv/bin/activate before running pip.
Step 1.2: Implement the demo GeoIP and reputation data
geo_data.py, the demo IP to location mapping
# geo_data.py
# Purpose: a small, fully reproducible IP-to-location table for lab use.
# Real production risk engines use a licensed GeoIP database (for example
# MaxMind GeoLite2 or GeoIP2); this lab intentionally avoids that licensing
# and connectivity dependency, and the mapping below covers only the
# addresses this lab's test scripts actually use.
DEMO_IP_LOCATIONS = {
"51.36.10.20": {"city": "Manchester, UK", "lat": 53.4808, "lon": -2.2426},
"51.36.10.21": {"city": "Manchester, UK", "lat": 53.4808, "lon": -2.2426},
"197.210.55.9": {"city": "Lagos, Nigeria", "lat": 6.5244, "lon": 3.3792},
"203.0.113.44": {"city": "Singapore", "lat": 1.3521, "lon": 103.8198},
}
# IP ranges/addresses treated as known-bad for this lab. In production this
# would be sourced from a threat intelligence feed, refreshed regularly.
DEMO_BAD_REPUTATION_IPS = {
"198.51.100.66",
}
def lookup(ip):
return DEMO_IP_LOCATIONS.get(ip)
def is_bad_reputation(ip):
return ip in DEMO_BAD_REPUTATION_IPS
Step 1.3: Implement the risk scoring logic
risk_logic.py, the scoring engine
# risk_logic.py
# Purpose: compute a risk score from impossible travel velocity, new
# device/IP combinations, and IP reputation.
import json
import math
import time
import redis
import geo_data
r = redis.Redis(host="ib-redis", port=6379, decode_responses=True)
HISTORY_TTL_SECONDS = 3600
def _haversine_km(lat1, lon1, lat2, lon2):
radius_km = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lambda = math.radians(lon2 - lon1)
a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
return 2 * radius_km * math.asin(math.sqrt(a))
def _history_key(username):
return f"login-history:{username}"
def score_login(username, source_ip, user_agent):
factors = []
score = 0
history_raw = r.get(_history_key(username))
current_location = geo_data.lookup(source_ip)
now = time.time()
if history_raw:
history = json.loads(history_raw)
previous_location = geo_data.lookup(history["ip"])
if current_location and previous_location:
distance_km = _haversine_km(
previous_location["lat"], previous_location["lon"],
current_location["lat"], current_location["lon"],
)
elapsed_hours = max((now - history["timestamp"]) / 3600.0, 0.01)
velocity_kmh = distance_km / elapsed_hours
# Commercial airline cruising speed is roughly 900 km/h; anything
# meaningfully above that between two logins is not physically
# plausible travel by the same person.
if velocity_kmh > 900:
score += 60
factors.append(
f"impossible_travel: {distance_km:.0f}km in "
f"{elapsed_hours*60:.0f} minutes ({velocity_kmh:.0f} km/h)"
)
seen_before = (history["ip"] == source_ip and history["user_agent"] == user_agent)
if not seen_before:
score += 20
factors.append("new_device_or_network")
else:
# First login ever seen for this user carries a small, non-blocking
# amount of risk since there is no baseline to compare against.
score += 10
factors.append("no_prior_history")
if geo_data.is_bad_reputation(source_ip):
score += 40
factors.append("known_bad_reputation_ip")
r.setex(
_history_key(username),
HISTORY_TTL_SECONDS,
json.dumps({"ip": source_ip, "user_agent": user_agent, "timestamp": now}),
)
return {"score": min(score, 100), "factors": factors}
Step 1.4: Implement the Flask HTTP layer
app.py, the risk engine's HTTP API
# app.py
import os
from flask import Flask, request, jsonify
import risk_logic
app = Flask(__name__)
BEARER_TOKEN = os.environ.get("RISK_ENGINE_BEARER_TOKEN", "change-me-in-production")
@app.before_request
def check_auth():
if request.path == "/healthz":
return
auth = request.headers.get("Authorization", "")
if auth != f"Bearer {BEARER_TOKEN}":
return jsonify({"error": "unauthorised"}), 401
@app.get("/healthz")
def healthz():
return jsonify({"status": "ok"})
@app.post("/score")
def score():
body = request.get_json(force=True)
result = risk_logic.score_login(
username=body["username"],
source_ip=body["sourceIp"],
user_agent=body.get("userAgent", "unknown"),
)
return jsonify(result)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8090)
Dockerfile and docker-compose.yml addition
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py risk_logic.py geo_data.py .
EXPOSE 8090
CMD ["gunicorn", "--bind", "0.0.0.0:8090", "--workers", "2", "app:app"]
ib-redis:
image: redis:7.2-alpine
container_name: ib-redis
networks:
- ib-lab-net
ib-risk-engine:
build: ./ib-risk-engine
container_name: ib-risk-engine
networks:
- ib-lab-net
environment:
RISK_ENGINE_BEARER_TOKEN: "REPLACE_WITH_STRONG_TOKEN"
ports:
- "8090:8090"
depends_on:
- ib-redis
Build and start
docker compose up -d --build ib-redis ib-risk-engine
curl -s http://localhost:8090/healthz and confirm {"status": "ok"}. Then run a manual score request: curl -s -X POST http://localhost:8090/score -H "Authorization: Bearer REPLACE_WITH_STRONG_TOKEN" -H "Content-Type: application/json" -d '{"username":"asmith","sourceIp":"51.36.10.20","userAgent":"test"}' and confirm a JSON body with a score field is returned. If the container exits immediately, check docker logs ib-risk-engine for a Redis connection error, which usually means ib-redis has not finished starting yet.
Phase 2: Write the Keycloak Authenticator SPI
Step 2.1: Scaffold the Maven project
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>uk.co.identitybytes</groupId>
<artifactId>risk-based-authenticator</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<keycloak.version>24.0.5</keycloak.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
provided scope tells Maven that Keycloak supplies these classes at runtime from its own module path; they must not be bundled into your JAR, since a duplicate copy of Keycloak's own classes on the providers classpath causes class loading conflicts.
Step 2.2: Implement the Authenticator
RiskBasedAuthenticator.java
package uk.co.identitybytes.risk;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.events.EventType;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.HashMap;
import javax.json.Json;
import javax.json.JsonObject;
// Calls the ib-risk-engine service after password validation and decides
// whether to continue the flow, force step-up MFA, or deny the login.
public class RiskBasedAuthenticator implements Authenticator {
private static final Duration TIMEOUT = Duration.ofSeconds(2);
@Override
public void authenticate(AuthenticationFlowContext context) {
RealmModel realm = context.getRealm();
UserModel user = context.getUser();
String riskEngineUrl = realm.getAttribute("risk-engine-url");
String bearerToken = realm.getAttribute("risk-engine-bearer-token");
String sourceIp = context.getConnection().getRemoteAddr();
String userAgent = context.getHttpRequest().getHttpHeaders()
.getRequestHeaders().getFirst("User-Agent");
try {
JsonObject requestBody = Json.createObjectBuilder()
.add("username", user.getUsername())
.add("sourceIp", sourceIp)
.add("userAgent", userAgent != null ? userAgent : "unknown")
.build();
HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(riskEngineUrl + "/score"))
.timeout(TIMEOUT)
.header("Authorization", "Bearer " + bearerToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody.toString()))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
JsonObject result = Json.createReader(new java.io.StringReader(response.body())).readObject();
int score = result.getInt("score");
applyDecision(context, score, result.getJsonArray("factors").toString());
} catch (Exception e) {
// Fail closed: if the risk engine is unreachable or returns an
// error, treat the login as high risk rather than allowing it
// through unchecked.
raiseAdminEvent(context, 100, "risk_engine_unreachable: " + e.getMessage());
context.getEvent().error("risk_engine_unreachable");
context.challenge(context.form()
.setError("Unable to verify login risk. Please contact IT support.")
.createErrorPage(javax.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE));
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
}
}
private void applyDecision(AuthenticationFlowContext context, int score, String factors) {
if (score < 30) {
context.success();
} else if (score < 70) {
raiseAdminEvent(context, score, factors);
// Marks the session so the subsequent OTP execution in the flow
// (built in Lab 05) is required rather than skipped, even for
// a user who might otherwise be within their OTP "remember me"
// window.
context.getAuthenticationSession().setAuthNote("force_otp_step_up", "true");
context.attempted();
} else {
raiseAdminEvent(context, score, factors);
context.getEvent().error("risk_denied_high_score");
context.challenge(context.form()
.setError("This login was blocked for security review. Contact IT support.")
.createErrorPage(javax.ws.rs.core.Response.Status.FORBIDDEN));
context.failure(AuthenticationFlowError.ACCESS_DENIED);
}
}
private void raiseAdminEvent(AuthenticationFlowContext context, int score, String factors) {
context.getEvent()
.event(EventType.CUSTOM_REQUIRED_ACTION)
.detail("risk_score", String.valueOf(score))
.detail("risk_factors", factors)
.success();
}
@Override
public void action(AuthenticationFlowContext context) {
// No user-facing form for this authenticator; it acts purely on
// the previous step's credential and the risk engine's response.
}
@Override
public boolean requiresUser() {
return true;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public void close() {
}
}
catch block is the single most important part of this class. An authenticator that fails open, silently calling context.success() when the risk engine cannot be reached, converts a security control into a control that only works when nothing has gone wrong, which is precisely when it is least useful.
Step 2.3: Implement the AuthenticatorFactory
RiskBasedAuthenticatorFactory.java
package uk.co.identitybytes.risk;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
public class RiskBasedAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "risk-based-authenticator";
private static final RiskBasedAuthenticator SINGLETON = new RiskBasedAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Risk-Based Step-Up (Identity Bytes)";
}
@Override
public String getReferenceCategory() {
return "risk";
}
@Override
public boolean isConfigurable() {
return true;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[]{
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED,
};
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public String getHelpText() {
return "Scores login risk via an external engine and forces step-up MFA or denies access based on the result.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}
SPI registration file
# src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
uk.co.identitybytes.risk.RiskBasedAuthenticatorFactory
Build the JAR
cd ~/ib-labs/risk-based-authenticator
mvn clean package
target/risk-based-authenticator-1.0.0.jar exists after the build. Run jar tf target/risk-based-authenticator-1.0.0.jar | grep META-INF/services and confirm the SPI registration file is present in the JAR; without it, Keycloak's provider discovery will not find your class no matter where the JAR is placed. If the Maven build fails with a missing dependency, confirm your keycloak.version in pom.xml matches the version running in your ib-keycloak-1 container, checked with docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh --version.
Phase 3: Deploy Across the HA Cluster and Wire the Flow
Step 3.1: Deploy the provider JAR to both Keycloak nodes without full downtime
Rolling provider deployment
# Copy the JAR to both nodes' providers directory
docker cp target/risk-based-authenticator-1.0.0.jar ib-keycloak-1:/opt/keycloak/providers/
docker cp target/risk-based-authenticator-1.0.0.jar ib-keycloak-2:/opt/keycloak/providers/
# Rebuild the Keycloak distribution inside each container so the new
# provider is registered, one node at a time so ib-lb keeps routing
# traffic to the other node throughout.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-1
# Wait for ib-keycloak-1 to report healthy before touching the second node
until curl -sk https://localhost:8443/health/ready | grep -q UP; do sleep 3; done
docker exec -it ib-keycloak-2 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-2
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get authentication/providers -r northgate | jq '.[] | select(.id=="risk-based-authenticator")'. Expect a single JSON object confirming the provider is registered. Repeat against ib-keycloak-2. If the provider is missing on one node only, the kc.sh build step likely did not complete before the restart; re-run it and confirm it exits with status 0 before restarting.
Step 3.2: Configure the realm attributes the authenticator reads
Set realm attributes via kcadm.sh
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/northgate \
-s 'attributes."risk-engine-url"=http://ib-risk-engine:8090' \
-s 'attributes."risk-engine-bearer-token"=REPLACE_WITH_STRONG_TOKEN'
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get realms/northgate --fields attributes | jq and confirm both attributes appear with the expected values. Because the realm lives in the shared PostgreSQL instance from Lab 06, this update is immediately visible from ib-keycloak-2 as well; no separate configuration step is needed on the second node.
Step 3.3: Duplicate the browser flow and insert the new execution
Duplicate and edit the flow (Admin Console steps, scripted equivalent below)
# Duplicate the existing browser flow so the original remains available
# as a rollback path
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
authentication/flows/browser/copy -r northgate \
-s newName="browser-risk-based"
# Add the new execution to the copied flow
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
authentication/flows/browser-risk-based/executions/execution -r northgate \
-s provider=risk-based-authenticator
# Set it to REQUIRED and position it after password validation, before OTP.
# In the Admin Console: Authentication, browser-risk-based, drag
# "Risk-Based Step-Up (Identity Bytes)" to sit directly beneath
# "Username Password Form" and above the Lab 05 "OTP Form" execution,
# then set its requirement to Required.
# Bind the new flow as the realm's active browser flow
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
realms/northgate -s browserFlow=browser-risk-based
browser-risk-based is marked as the realm's bound browser flow, with "Risk-Based Step-Up (Identity Bytes)" listed as Required between the password form and the OTP form. If the execution does not appear in the dropdown when adding it, the provider was not correctly registered in Step 3.1; re-verify with the kcadm.sh get authentication/providers command from that step.
browser flow. Keeping it available and unbound gives you an immediate rollback path (kcadm.sh update realms/northgate -s browserFlow=browser) if the new flow misbehaves during testing.
08Testing and Validation
End-to-End Test Scenarios
| Scenario | Steps | Expected Result |
|---|---|---|
| Low risk, familiar login | Authenticate as asmith from 51.36.10.20, the demo Manchester IP, using a device already seen once | Login proceeds directly to token issuance, no OTP prompt shown |
| Medium risk, new device | Authenticate as asmith again immediately, changing only the User-Agent header to an unseen value | Score lands in the 30 to 69 range; OTP step-up is forced even if asmith's device was previously remembered |
| High risk, impossible travel | Authenticate as asmith from 51.36.10.20, then within two minutes authenticate again from 197.210.55.9, the demo Lagos IP | Score reaches 70 or above; login is denied with a 403 and an admin event is raised |
Impossible travel test script
#!/usr/bin/env bash
set -euo pipefail
TOKEN="REPLACE_WITH_STRONG_TOKEN"
echo "First login, Manchester:"
curl -s -X POST http://localhost:8090/score \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"username":"asmith","sourceIp":"51.36.10.20","userAgent":"lab-test"}' | jq .
sleep 2
echo "Second login, Lagos, 2 seconds later:"
curl -s -X POST http://localhost:8090/score \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"username":"asmith","sourceIp":"197.210.55.9","userAgent":"lab-test"}' | jq .
# Expect the second call's score to include "impossible_travel" in its
# factors array and a total score of 70 or above.
Negative Tests
| Test | Expected Result |
|---|---|
Stop ib-risk-engine with docker stop ib-risk-engine, then attempt a login | Authenticator's HTTP call times out after two seconds; login fails closed with a service unavailable error, not a silent allow |
Call /score with no Authorization header | HTTP 401 returned by the risk engine |
Call /score with a username never seen before | Score includes no_prior_history as a low-weight factor, not a crash |
Common Failure Modes
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Authenticator does not appear in the flow execution dropdown | SPI registration file missing from the JAR, or kc.sh build not run after copying the JAR | Re-check Step 2.3's verification and Step 3.1's build step |
| Login always succeeds regardless of the scenario tested | The realm's active browser flow is still the original browser flow, not browser-risk-based | Confirm with kcadm.sh get realms/northgate --fields browserFlow |
| Score is always identical across two different source IPs | The demo GeoIP table in geo_data.py does not contain the IP you tested with | Add the IP to DEMO_IP_LOCATIONS, or use one of the IPs already listed there |
| One node applies the flow correctly and the other does not | The JAR or the kc.sh build step was only performed on one node | Repeat Step 3.1 against the node that is missing the provider |
09Security Analysis
What Makes This Implementation Secure
- The authenticator fails closed: any exception calling the risk engine, including a timeout, results in a denied or challenged login, never a silent success.
- Risk scoring executes after password validation, so an unauthenticated attacker cannot use the risk engine's response to enumerate valid usernames.
- The risk engine's bearer token is stored as a realm attribute rather than compiled into the JAR, allowing it to be rotated without rebuilding and redeploying the provider.
- Every medium and high risk decision raises a structured Keycloak admin event carrying the specific contributing factors, giving Lab 33's incident response process concrete evidence rather than a bare score.
What Is Intentionally Simplified for the Lab
- The GeoIP and IP reputation data is a small static table covering only the addresses this lab's test scripts use, not a licensed, continuously updated GeoIP database or threat intelligence feed.
- Traffic between the authenticator and the risk engine runs over plain HTTP within the isolated lab Docker network; production deployments carrying this traffic across a real network boundary need mutual TLS.
- The risk thresholds and point weights in
risk_logic.pyare illustrative starting points, not values derived from real fraud analysis. - Login history in Redis has no authentication of its own beyond network isolation; a production deployment would add Redis AUTH and, ideally, TLS.
Production Hardening Recommendations
| Area | Recommendation |
|---|---|
| Threat intelligence | Replace the static demo reputation list with a subscribed threat intelligence feed, refreshed on a defined schedule |
| GeoIP accuracy | Replace the demo location table with a licensed GeoIP database such as MaxMind GeoIP2, refreshed regularly per the vendor's update cadence |
| Transport security | Enforce mutual TLS between the authenticator and the risk engine, with certificates issued by the internal CA built in the Phase 4 PKI labs |
| Threshold tuning | Run the risk engine in shadow mode (logging scores without enforcing them) against real login traffic before switching enforcement on, to calibrate thresholds against actual false positive rates |
| Provider deployment | Bake the provider JAR into a custom Keycloak container image built by CI, rather than copying it into a running container, so every replica in an orchestrated environment starts consistently configured |
10Cleanup
Revert to the original browser flow while keeping the risk engine available
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
realms/northgate -s browserFlow=browser
docker compose stop ib-risk-engine ib-redis
docker compose rm -f ib-risk-engine ib-redis
Remove the custom provider entirely (optional, full teardown only)
docker exec -it ib-keycloak-1 rm /opt/keycloak/providers/risk-based-authenticator-1.0.0.jar
docker exec -it ib-keycloak-2 rm /opt/keycloak/providers/risk-based-authenticator-1.0.0.jar
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-1
until curl -sk https://localhost:8443/health/ready | grep -q UP; do sleep 3; done
docker exec -it ib-keycloak-2 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-2
docker ps no longer lists ib-risk-engine or ib-redis, while ib-openldap, ib-keycloak-1, ib-keycloak-2, ib-lb and ib-postgres remain healthy and ready for Lab 09.
11Recommended Learning Links
- Keycloak Server Developer Guide, Authentication SPI chapter, Keycloak documentation
- Keycloak Server Developer Guide, Providers and Provider Factories, Keycloak documentation
- NIST SP 800-63B, Digital Identity Guidelines, Authentication and Lifecycle Management
- PSD2 Regulatory Technical Standards on Strong Customer Authentication, European Banking Authority
- OWASP Authentication Cheat Sheet, risk-based authentication section
- Microsoft Entra ID Protection documentation, risk detection types, as a reference implementation of the same concepts at commercial scale
12Portfolio Publishing Guide
Sanitise Before Publishing
Before pushing this lab's code to a public repository, remove every placeholder secret and confirm the Maven build artefacts are excluded.
Sanitisation checklist commands
grep -R "REPLACE_WITH" . --include="*.java" --include="*.py" --include="*.yml" || echo "Clean"
cat >> .gitignore <<'EOF'
target/
.venv/
*.jar
.env
EOF
git status
README for the Repository
README.md skeleton
# IB-SIA-08: Adaptive and Risk-Based Authentication
A custom Keycloak Authenticator SPI (Java 17) that scores login risk via an
external Flask engine (impossible travel, new device, IP reputation) and
forces step-up MFA or denies access, deployed across a Keycloak HA cluster.
## Stack
Java 17, Keycloak SPI, Maven 3.9, Python 3.11, Flask 3.0, Redis 7.2
## Scenarios demonstrated
- Low risk: familiar device and location, no step-up required
- Medium risk: new device, forces OTP step-up
- High risk: impossible travel, login denied and admin event raised
## Part of the Identity Bytes Senior IAM Architect Track
Lab 8 of 36. See identity-bytes.com for the full curriculum.
Git Commands
Commit and push
git add pom.xml src/ app.py risk_logic.py geo_data.py Dockerfile README.md
git commit -m "IB-SIA-08: Adaptive risk-based authentication via custom Keycloak SPI"
git push origin main
Track Index Line
Add the following line to your master portfolio index:
IB-SIA-08 | Adaptive and Risk-Based Authentication | Advanced | Keycloak SPI, Java, risk scoring, HA deployment
LinkedIn Draft
A correct password and a valid one-time code are not the same thing as a legitimate login.
This week I wrote a custom Keycloak Authenticator in Java that scores every login for risk before a token is ever issued. Twelve minutes between a login in Manchester and a login in Lagos is not a slow VPN, it is not physically possible travel, and a static authentication flow has no way to know that.
The engineering challenge was not the risk logic itself, that part is a straightforward set of rules. It was deploying a custom SPI provider consistently across a two-node Keycloak cluster without taking the login flow down, and deciding what should happen when the risk engine itself does not respond. A security control that quietly lets everyone through the moment it breaks is not a security control, so the authenticator fails closed and forces step-up MFA whenever it cannot get a clear answer.
Static multi-factor authentication asks "did you prove who you are." Risk-based authentication also asks "does this specific login make sense."
Where in your identity stack are you still treating every login as equally trustworthy regardless of context?