Skip to content
Merged
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
14 changes: 11 additions & 3 deletions .github/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,30 @@
# KC_DB_USERNAME — Keycloak database user
# KC_DB_PASSWORD — Keycloak database password

# Keycloak version — passed at build time from the workflow env.KEYCLOAK_VERSION.
# Never hardcode it here — change KEYCLOAK_VERSION in the workflow and it propagates
# to both the Maven build (via -Dversion.keycloak) and the Keycloak base images.
ARG KEYCLOAK_VERSION

# === Stage 1: Build the provider JAR with Maven ===
FROM maven:3-eclipse-temurin-17 AS maven

# Re-declare so it is available inside this stage
ARG KEYCLOAK_VERSION
ARG BUILD_TIMESTAMP
ARG JAR_CHECKSUM

WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests -q
# Override pom.xml <version.keycloak> with the value from the workflow
RUN mvn clean package -Dversion.keycloak=${KEYCLOAK_VERSION} -DskipTests -q

# Build metadata for cache invalidation
RUN echo "Build timestamp: ${BUILD_TIMESTAMP}" > /tmp/build-info.txt && \
echo "JAR checksum: ${JAR_CHECKSUM}" >> /tmp/build-info.txt

# === Stage 2: Build Keycloak with the provider JAR ===
FROM quay.io/keycloak/keycloak:26.5.1 AS builder
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} AS builder

ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
Expand All @@ -59,7 +67,7 @@ RUN /opt/keycloak/bin/kc.sh build

# === Final Runtime Image ===
# Use the same Keycloak base image for the final runtime image
FROM quay.io/keycloak/keycloak:26.5.1
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}

COPY --from=builder /opt/keycloak/ /opt/keycloak/
COPY --from=maven /tmp/build-info.txt /opt/keycloak/
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ hs_err_pid*
*.iml
out
gen

# VSCode
.vscode/

# Metals (Scala LSP)
.metals/
### macOS template
# General
.DS_Store
Expand Down
6 changes: 4 additions & 2 deletions development/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
# - OBP themes are always included. Activate the 'obp' login theme via the
# Admin Console: Realm Settings > Themes > Login Theme.
#
# Build arguments (defaults chosen to match development state):
ARG KEYCLOAK_VERSION=26.5.1
# Keycloak version — must be passed explicitly. The canonical version is defined in
# KEYCLOAK_VERSION in .github/workflows/build_container_main_branch_themed.yml.
# Pass with: --build-arg KEYCLOAK_VERSION=<version>
ARG KEYCLOAK_VERSION

ARG BUILD_TIMESTAMP
ARG JAR_CHECKSUM
Expand Down
38 changes: 21 additions & 17 deletions development/run-local-postgres-cicd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ source .env
KEYCLOAK_HOST="${KEYCLOAK_HOST:-localhost}"

# Validate required vars
required_vars=("KC_DB_URL" "KC_DB_USERNAME" "KC_DB_PASSWORD" "OBP_API_URL" "OBP_API_USERNAME" "OBP_API_PASSWORD" "OBP_API_CONSUMER_KEY" "OBP_AUTHUSER_PROVIDER")
required_vars=("KC_DB_URL" "KC_DB_USERNAME" "KC_DB_PASSWORD" "OBP_API_URL" "OBP_API_USERNAME" "OBP_API_PASSWORD" "OBP_API_CONSUMER_KEY" "OBP_AUTHUSER_PROVIDER" "KEYCLOAK_VERSION")
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo -e "${RED}✗ Missing environment variable: $var${NC}"
Expand Down Expand Up @@ -213,11 +213,13 @@ echo -e "${CYAN}[6/8] Building Docker Image${NC}"
echo "Building with:"
echo " Dockerfile: $DOCKERFILE_PATH"
echo " Image tag: $IMAGE_TAG"
echo " Keycloak version: $KEYCLOAK_VERSION"

# Force rebuild with cache invalidation; capture output for diagnostics
DOCKER_BUILD_LOG=$(mktemp)
docker build \
--no-cache \
--build-arg KEYCLOAK_VERSION="$KEYCLOAK_VERSION" \
--build-arg BUILD_TIMESTAMP="$BUILD_TIMESTAMP" \
--build-arg JAR_CHECKSUM="$JAR_CHECKSUM" \
-t "$IMAGE_TAG" \
Expand All @@ -240,30 +242,34 @@ echo -e "${GREEN}✓ Docker image built${NC}"
# Step 7: Start new container
echo -e "${CYAN}[7/8] Starting New Container${NC}"

# Translate localhost/127.0.0.1 in OBP_API_URL to host.docker.internal so the
# provider inside the container can reach OBP running on the host.
# (Inside Docker, 127.0.0.1 resolves to the container itself, not the host.)
CONTAINER_OBP_API_URL="${OBP_API_URL//127.0.0.1/host.docker.internal}"
CONTAINER_OBP_API_URL="${CONTAINER_OBP_API_URL//localhost/host.docker.internal}"
if [ "$CONTAINER_OBP_API_URL" != "$OBP_API_URL" ]; then
echo -e "${BLUE} OBP_API_URL rewritten for container networking:${NC}"
echo " host: $OBP_API_URL"
echo " container: $CONTAINER_OBP_API_URL"
# Use --network host so the container shares the host's network namespace.
# This means 127.0.0.1 inside the container IS the host's loopback — OBP is
# directly reachable without any address translation.
#
# KC_DB_URL may contain host.docker.internal (written for bridge-mode Docker).
# Rewrite it to localhost so Postgres is reachable via the host's loopback.
CONTAINER_KC_DB_URL="${KC_DB_URL//host.docker.internal/localhost}"
if [ "$CONTAINER_KC_DB_URL" != "$KC_DB_URL" ]; then
echo -e "${BLUE} KC_DB_URL rewritten for host network mode:${NC}"
echo " .env: $KC_DB_URL"
echo " container: $CONTAINER_KC_DB_URL"
fi

# Container environment variables
CONTAINER_ENV_VARS=(
"-e" "KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin}"
"-e" "KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-admin}"
"-e" "KC_DB=postgres"
"-e" "KC_DB_URL=$KC_DB_URL"
"-e" "KC_DB_URL=$CONTAINER_KC_DB_URL"
"-e" "KC_DB_USERNAME=$KC_DB_USERNAME"
"-e" "KC_DB_PASSWORD=$KC_DB_PASSWORD"
"-e" "OBP_API_URL=$CONTAINER_OBP_API_URL"
"-e" "OBP_API_URL=$OBP_API_URL"
"-e" "OBP_API_USERNAME=$OBP_API_USERNAME"
"-e" "OBP_API_PASSWORD=$OBP_API_PASSWORD"
"-e" "OBP_API_CONSUMER_KEY=$OBP_API_CONSUMER_KEY"
"-e" "OBP_AUTHUSER_PROVIDER=$OBP_AUTHUSER_PROVIDER"
# Move Keycloak off port 8080 to avoid conflicting with OBP on the shared host network
"-e" "KC_HTTP_PORT=${KEYCLOAK_HTTP_PORT:-7787}"
"-e" "KC_HOSTNAME_STRICT=${KC_HOSTNAME_STRICT:-false}"
"-e" "KC_HTTP_ENABLED=${KC_HTTP_ENABLED:-true}"
"-e" "KC_HEALTH_ENABLED=${KC_HEALTH_ENABLED:-true}"
Expand All @@ -272,13 +278,11 @@ CONTAINER_ENV_VARS=(
"-e" "FORGOT_PASSWORD_URL=${FORGOT_PASSWORD_URL:-}"
)

# Start container
# Start container with host networking — no port mapping needed, Keycloak binds
# directly to host ports (HTTP: ${KEYCLOAK_HTTP_PORT:-7787}, HTTPS: 8443, mgmt: 9000)
docker run -d \
--name "$CONTAINER_NAME" \
-p "${KEYCLOAK_HTTP_PORT:-7787}:8080" \
-p "${KEYCLOAK_HTTPS_PORT:-8443}:8443" \
-p "${KEYCLOAK_MGMT_PORT:-9000}:9000" \
--add-host=host.docker.internal:host-gateway \
--network host \
"${CONTAINER_ENV_VARS[@]}" \
"$IMAGE_TAG" > /dev/null 2>&1

Expand Down
17 changes: 11 additions & 6 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<?xml version="1.0" encoding="UTF-8" ?>
<project
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
>
<modelVersion>4.0.0</modelVersion>

<groupId>io.tesobe</groupId>
<artifactId>obp-keycloak-provider</artifactId>
<version>2.0.0</version>
<version>2.0.1</version>

<name>obp-keycloak-provider</name>
<url>http://www.example.com</url>
Expand All @@ -15,6 +17,8 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<!-- Local dev default. CI/CD overrides this via -Dversion.keycloak from KEYCLOAK_VERSION
defined in .github/workflows/build_container_main_branch_themed.yml -->
<version.keycloak>26.5.1</version.keycloak>
<version.compiler.maven.plugin>3.5.1</version.compiler.maven.plugin>
<version.jakarta.persistence>3.1.0</version.jakarta.persistence>
Expand Down Expand Up @@ -137,7 +141,8 @@
<phase>process-resources</phase>
<goals><goal>copy-resources</goal></goals>
<configuration>
<outputDirectory>${project.build.directory}/themes-filtered</outputDirectory>
<outputDirectory
>${project.build.directory}/themes-filtered</outputDirectory>
<useDefaultDelimiters>false</useDefaultDelimiters>
<delimiters><delimiter>@</delimiter></delimiters>
<resources>
Expand Down
38 changes: 22 additions & 16 deletions src/main/java/io/tesobe/model/UserAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@ public UserAdapter(
// entity.getId() is already a String (UUID), no conversion needed
this.keycloakId = StorageId.keycloakId(model, entity.getId());

// Clear any existing federated storage data to ensure database is source of truth
clearFederatedStorageAttributes();

// Add timestamp to ensure fresh data retrieval
log.infof(
"UserAdapter initialized at %d for user: %s (user_id: %s)",
System.currentTimeMillis(),
Expand All @@ -81,8 +77,8 @@ public String getId() {
@Override
public String getUsername() {
String value = entity.getUsername();
log.infof(
"getUsername() for user %s returning DATABASE value: '%s'",
log.debugf(
"getUsername() for user %s returning API value: '%s'",
value,
value
);
Expand All @@ -92,8 +88,8 @@ public String getUsername() {
@Override
public String getEmail() {
String value = entity.getEmail();
log.infof(
"getEmail() for user %s returning DATABASE value: '%s'",
log.debugf(
"getEmail() for user %s returning API value: '%s'",
getUsername(),
value
);
Expand All @@ -103,8 +99,8 @@ public String getEmail() {
@Override
public String getFirstName() {
String value = entity.getFirstName();
log.infof(
"getFirstName() for user %s returning DATABASE value: '%s'",
log.debugf(
"getFirstName() for user %s returning API value: '%s'",
getUsername(),
value
);
Expand All @@ -114,8 +110,8 @@ public String getFirstName() {
@Override
public String getLastName() {
String value = entity.getLastName();
log.infof(
"getLastName() for user %s returning DATABASE value: '%s'",
log.debugf(
"getLastName() for user %s returning API value: '%s'",
getUsername(),
value
);
Expand All @@ -132,6 +128,13 @@ public boolean isEnabled() {
return Boolean.TRUE.equals(entity.getValidated());
}

@Override
public Long getCreatedTimestamp() {
// OBP API does not expose an account creation date.
// Return current time so the admin console shows a valid date rather than "Invalid Date".
return System.currentTimeMillis();
}

// Custom methods for password validation
public String getPassword() {
return entity.getPassword();
Expand Down Expand Up @@ -204,8 +207,8 @@ public String getFirstAttribute(String name) {
}
value = null;
}
log.infof(
"getFirstAttribute(%s) for user %s returning DATABASE value: '%s'",
log.debugf(
"getFirstAttribute(%s) for user %s returning API value: '%s'",
name,
getUsername(),
value
Expand All @@ -215,7 +218,7 @@ public String getFirstAttribute(String name) {

@Override
public Map<String, List<String>> getAttributes() {
log.infof("getAttributes() called for user: %s", getUsername());
log.debugf("getAttributes() called for user: %s", getUsername());

// Check what federated storage contains before we return database-only values
Map<String, List<String>> federatedAttributes = super.getAttributes();
Expand Down Expand Up @@ -397,7 +400,10 @@ public void removeAttribute(String name) {

@Override
public Stream<String> getRequiredActionsStream() {
return super.getRequiredActionsStream();
// Database is source of truth — never surface stored required actions such as
// UPDATE_PROFILE from federated storage, as users cannot update their profile
// through Keycloak and the form would appear on every login with no way to dismiss it.
return Stream.empty();
}

@Override
Expand Down
24 changes: 14 additions & 10 deletions src/main/java/io/tesobe/providers/KcUserStorageProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -147,32 +147,34 @@ public boolean supportsCredentialType(String credentialType) {

@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
// In API mode there is no locally stored hash to check —
// any federated user is assumed to have a password managed by OBP.
return supportsCredentialType(credentialType);
boolean supported = supportsCredentialType(credentialType);
log.warnf("isConfiguredFor() — user: %s, credentialType: %s → %b",
user.getUsername(), credentialType, supported);
return supported;
}

@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
log.infof("isValid() called for user: %s", user.getUsername());
log.warnf(">>> isValid() called for user: %s, credentialType: %s <<<",
user.getUsername(), input.getType());

if (!supportsCredentialType(input.getType())) {
log.warnf("Unsupported credential type: %s for user: %s",
log.warnf("isValid() — unsupported credential type: %s for user: %s",
input.getType(), user.getUsername());
return false;
}

String rawPassword = input.getChallengeResponse();
if (rawPassword == null || rawPassword.trim().isEmpty()) {
log.warnf("Empty or null password provided for user: %s", user.getUsername());
log.warnf("isValid() — empty or null password for user: %s", user.getUsername());
return false;
}

boolean valid = apiClient.verifyUserCredentials(user.getUsername(), rawPassword) != null;
if (valid) {
log.infof("Password validation SUCCESSFUL for user: %s", user.getUsername());
log.warnf("isValid() — password validation SUCCESSFUL for user: %s", user.getUsername());
} else {
log.warnf("Password validation FAILED for user: %s", user.getUsername());
log.warnf("isValid() — password validation FAILED for user: %s", user.getUsername());
}
return valid;
}
Expand Down Expand Up @@ -201,8 +203,10 @@ public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, User

@Override
public int getUsersCount(RealmModel realm) {
log.infof("getUsersCount() called — returning 0 (count not available via OBP API)");
return 0;
List<KcUserEntity> users = apiClient.listUsers(0, 0);
int count = users.size();
log.infof("getUsersCount() — found %d users for configured provider", count);
return count;
}

@Override
Expand Down
Loading