DEV Community

DEV-AI
DEV-AI

Posted on

Custom SPI for Automatic User Registration After External Provider Authentication

A complete implementation guide for creating a custom Keycloak SPI that automatically registers users when they don't exist in the system after successful external provider authentication.

Prerequisites

  • Java Development Kit (JDK) 11 or higher
  • Apache Maven for dependency management
  • Keycloak Server 18.x or higher
  • IDE (IntelliJ IDEA or Eclipse)

Maven Dependencies

<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-services</artifactId>
        <version>${keycloak.version}</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.15.2</version>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

Project Structure

src/
└── main/
    ├── java/
    │   └── com/
    │       └── company/
    │           └── keycloak/
    │               └── provider/
    │                   ├── CustomIdentityProvider.java
    │                   ├── CustomIdentityProviderConfig.java
    │                   ├── CustomIdentityProviderFactory.java
    │                   └── model/
    │                       └── UserInfo.java
    └── resources/
        └── META-INF/
            └── services/
                └── org.keycloak.broker.provider.IdentityProviderFactory
Enter fullscreen mode Exit fullscreen mode

Implementation

1. User Information Model

Create a model class to hold user information from the external provider:

package com.company.keycloak.provider.model;

public class UserInfo {
    private String username;
    private String firstName;
    private String lastName;
    private String email;
    private String phoneNumber;
    private String externalUserId;
    private String nationalId;
    private String dateOfBirth;
    private String gender;

    // Constructors
    public UserInfo() {}

    public UserInfo(String username, String firstName, String lastName, String email) {
        this.username = username;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
    }

    // Getters and setters
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getFirstName() { return firstName; }
    public void setFirstName(String firstName) { this.firstName = firstName; }

    public String getLastName() { return lastName; }
    public void setLastName(String lastName) { this.lastName = lastName; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPhoneNumber() { return phoneNumber; }
    public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }

    public String getExternalUserId() { return externalUserId; }
    public void setExternalUserId(String externalUserId) { this.externalUserId = externalUserId; }

    public String getNationalId() { return nationalId; }
    public void setNationalId(String nationalId) { this.nationalId = nationalId; }

    public String getDateOfBirth() { return dateOfBirth; }
    public void setDateOfBirth(String dateOfBirth) { this.dateOfBirth = dateOfBirth; }

    public String getGender() { return gender; }
    public void setGender(String gender) { this.gender = gender; }
}
Enter fullscreen mode Exit fullscreen mode

2. Identity Provider Configuration

package com.company.keycloak.provider;

import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;

public class CustomIdentityProviderConfig extends OAuth2IdentityProviderConfig {

    public CustomIdentityProviderConfig(IdentityProviderModel model) {
        super(model);
    }

    public String getApiUrl() {
        return getConfig().get("apiUrl");
    }

    public void setApiUrl(String apiUrl) {
        getConfig().put("apiUrl", apiUrl);
    }

    public String getApiKey() {
        return getConfig().get("apiKey");
    }

    public void setApiKey(String apiKey) {
        getConfig().put("apiKey", apiKey);
    }

    public boolean isAutoCreateUsers() {
        return Boolean.parseBoolean(getConfig().getOrDefault("autoCreateUsers", "true"));
    }

    public void setAutoCreateUsers(boolean autoCreateUsers) {
        getConfig().put("autoCreateUsers", String.valueOf(autoCreateUsers));
    }

    public String getDefaultUserRole() {
        return getConfig().getOrDefault("defaultUserRole", "user");
    }

    public void setDefaultUserRole(String defaultUserRole) {
        getConfig().put("defaultUserRole", defaultUserRole);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Core Identity Provider Implementation

package com.company.keycloak.provider;

import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.models.*;
import org.keycloak.sessions.AuthenticationSessionModel;
import com.company.keycloak.provider.model.UserInfo;

import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

public class CustomIdentityProvider extends AbstractIdentityProvider<CustomIdentityProviderConfig> {

    private static final String PROVIDER_ID = "custom-external";

    public CustomIdentityProvider(KeycloakSession session, CustomIdentityProviderConfig config) {
        super(session, config);
    }

    @Override
    public Object callback(RealmModel realm, AuthenticationCallback callback, EventBuilder event) {
        return null;
    }

    @Override
    public Response performLogin(AuthenticationRequest request) {
        try {
            AuthenticationSessionModel authSession = request.getAuthenticationSession();

            // Extract parameters from the request
            String userId = request.getHttpRequest().getUri().getQueryParameters().getFirst("user_id");
            String transactionId = request.getHttpRequest().getUri().getQueryParameters().getFirst("transaction_id");

            // Validate parameters
            if (!validateParameters(userId, transactionId)) {
                throw new IdentityBrokerException("Invalid parameters provided");
            }

            // Store parameters in authentication session
            authSession.setAuthNote("external_user_id", userId);
            authSession.setAuthNote("transaction_id", transactionId);

            // Call external API to get user information
            UserInfo userInfo = getExternalUserInfo(userId, transactionId);

            // Create brokered identity context
            BrokeredIdentityContext context = createIdentityContext(userInfo);

            // Return callback response
            return callback.authenticated(context);

        } catch (Exception e) {
            throw new IdentityBrokerException("Authentication failed", e);
        }
    }

    @Override
    public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) {
        // This method is called when a new user needs to be created

        // Set basic user properties
        user.setEnabled(true);
        user.setEmailVerified(true);

        // Map attributes from external provider context
        mapUserAttributes(user, context);

        // Add default roles
        addDefaultRoles(realm, user);

        // Set required actions for new users
        user.addRequiredAction("UPDATE_PROFILE");

        // Log user creation
        logger.infof("New user created from external provider: %s", user.getUsername());
    }

    @Override
    public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, BrokeredIdentityContext context) {
        // Update existing user attributes
        mapUserAttributes(user, context);
        logger.infof("Updated existing user from external provider: %s", user.getUsername());
    }

    private boolean validateParameters(String userId, String transactionId) {
        if (userId == null || userId.trim().isEmpty() || userId.length() > 50) {
            return false;
        }

        if (transactionId == null || transactionId.trim().isEmpty() || transactionId.length() > 100) {
            return false;
        }

        Pattern userIdPattern = Pattern.compile("^[a-zA-Z0-9_-]+$");
        Pattern transactionIdPattern = Pattern.compile("^[a-zA-Z0-9_-]+$");

        return userIdPattern.matcher(userId).matches() && 
               transactionIdPattern.matcher(transactionId).matches();
    }

    private UserInfo getExternalUserInfo(String userId, String transactionId) {
        // Simulate external API call
        // In real implementation, make HTTP call to external service

        UserInfo userInfo = new UserInfo();
        userInfo.setUsername("user_" + userId);
        userInfo.setFirstName("John");
        userInfo.setLastName("Doe");
        userInfo.setEmail("john.doe@example.com");
        userInfo.setPhoneNumber("+1234567890");
        userInfo.setExternalUserId(userId);
        userInfo.setNationalId("123456789");
        userInfo.setDateOfBirth("1990-01-01");
        userInfo.setGender("M");

        return userInfo;
    }

    private BrokeredIdentityContext createIdentityContext(UserInfo userInfo) {
        BrokeredIdentityContext context = new BrokeredIdentityContext(userInfo.getExternalUserId());

        context.setUsername(userInfo.getUsername());
        context.setFirstName(userInfo.getFirstName());
        context.setLastName(userInfo.getLastName());
        context.setEmail(userInfo.getEmail());
        context.setIdpConfig(getConfig());
        context.setIdp(this);

        // Add custom attributes to context
        context.getContextData().put("phone_number", userInfo.getPhoneNumber());
        context.getContextData().put("national_id", userInfo.getNationalId());
        context.getContextData().put("date_of_birth", userInfo.getDateOfBirth());
        context.getContextData().put("gender", userInfo.getGender());
        context.getContextData().put("external_user_id", userInfo.getExternalUserId());

        return context;
    }

    private void mapUserAttributes(UserModel user, BrokeredIdentityContext context) {
        // Map standard attributes
        if (context.getFirstName() != null) {
            user.setFirstName(context.getFirstName());
        }

        if (context.getLastName() != null) {
            user.setLastName(context.getLastName());
        }

        if (context.getEmail() != null) {
            user.setEmail(context.getEmail());
        }

        // Map custom attributes from context
        Map<String, Object> contextAttributes = context.getContextData();
        if (contextAttributes != null) {
            mapStringAttribute(user, contextAttributes, "external_user_id");
            mapStringAttribute(user, contextAttributes, "national_id");
            mapStringAttribute(user, contextAttributes, "phone_number");
            mapStringAttribute(user, contextAttributes, "date_of_birth");
            mapStringAttribute(user, contextAttributes, "gender");
        }

        // Set authentication source and timestamp
        user.setSingleAttribute("auth_source", "external_provider");
        user.setSingleAttribute("registration_timestamp", String.valueOf(System.currentTimeMillis()));
    }

    private void mapStringAttribute(UserModel user, Map<String, Object> contextAttributes, String attributeName) {
        Object value = contextAttributes.get(attributeName);
        if (value != null) {
            user.setSingleAttribute(attributeName, value.toString());
        }
    }

    private void addDefaultRoles(RealmModel realm, UserModel user) {
        // Add default realm roles
        RoleModel defaultRole = realm.getRole("default-roles-" + realm.getName());
        if (defaultRole != null) {
            user.grantRole(defaultRole);
        }

        // Add custom role for external provider users
        String customRoleName = getConfig().getDefaultUserRole();
        RoleModel customRole = realm.getRole(customRoleName);
        if (customRole != null) {
            user.grantRole(customRole);
        }
    }

    @Override
    public BrokeredIdentityContext getFederatedIdentity(Response response) {
        // This method processes the response from external provider
        // Implementation depends on the specific external provider's response format
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Identity Provider Factory

package com.company.keycloak.provider;

import org.keycloak.broker.provider.AbstractIdentityProviderFactory;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;

public class CustomIdentityProviderFactory extends AbstractIdentityProviderFactory<CustomIdentityProvider> {

    public static final String PROVIDER_ID = "custom-external";

    @Override
    public String getName() {
        return "Custom External Provider";
    }

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

    @Override
    public CustomIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
        return new CustomIdentityProvider(session, new CustomIdentityProviderConfig(model));
    }

    @Override
    public CustomIdentityProviderConfig createConfig() {
        return new CustomIdentityProviderConfig(new IdentityProviderModel());
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Service Provider Registration

Create the file META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory:

com.company.keycloak.provider.CustomIdentityProviderFactory
Enter fullscreen mode Exit fullscreen mode

Building and Deployment

1. Build the Project

mvn clean package
Enter fullscreen mode Exit fullscreen mode

2. Deploy to Keycloak

  1. Copy the generated JAR file to the providers/ directory of your Keycloak installation
  2. Restart Keycloak server
  3. Check server logs for any errors during startup

3. Configuration in Keycloak Admin Console

  1. Add Identity Provider:

    • Navigate to your realm in Keycloak Admin Console
    • Go to Identity Providers
    • Click "Add provider" and select "Custom External Provider"
    • Configure the API URL, API Key, and other settings
  2. Configure Authentication Flow:

    • Go to Authentication → Flows
    • Create a new flow or modify existing "First Broker Login" flow
    • Ensure the flow handles user creation properly
  3. Set Default Roles:

    • Go to Realm Settings → Roles
    • Create necessary roles (e.g., "external-user")
    • Configure default role mappings

Testing the Implementation

1. Test URL Format

https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth?
  client_id=myclient&
  response_type=code&
  redirect_uri=https://app.example.com/callback&
  user_id=12345&
  transaction_id=tx_abc123
Enter fullscreen mode Exit fullscreen mode

2. Verify User Creation

  1. Access the test URL with valid parameters
  2. Check that the user is created in Keycloak
  3. Verify that custom attributes are properly mapped
  4. Confirm that default roles are assigned

Security Considerations

  • Input Validation: Always validate and sanitize input parameters
  • API Security: Use HTTPS for all external API calls
  • Error Handling: Implement proper error handling and logging
  • Duplicate Prevention: Check for existing users before creation
  • Audit Logging: Log all authentication attempts and user creations

This implementation provides a complete solution for automatically registering users in Keycloak after successful external provider authentication, with proper validation, error handling, and security measures.

keycloack

Top comments (0)