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
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<artifactId>message-otp-authenticator</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>sequent</groupId>
<artifactId>oauth2-client</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Keycloak dependencies -->
<dependency>
<groupId>org.keycloak</groupId>
Expand Down Expand Up @@ -184,6 +189,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<include>javax:*</include>
<include>software.amazon.awssdk:*</include>
<include>org.reactivestreams:reactive-streams</include>
<include>sequent:oauth2-client</include>
</includes>
</artifactSet>
</configuration>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand All @@ -36,7 +35,7 @@
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
import org.keycloak.util.JsonSerialization;
import sequent.keycloak.oauth2.ClientCredentialsTokenClient;

/**
* Mappings UserModel.attribute to an ID Token claim. Token claim name can be a full qualified
Expand Down Expand Up @@ -312,44 +311,17 @@ public static ProtocolMapperModel createClaimMapper(
}

public String authenticate(String tenantId) {
HttpClient client = HttpClient.newHttpClient();
String url =
this.keycloakUrl
+ "/realms/"
+ getTenantRealmName(tenantId)
+ "/protocol/openid-connect/token";
Map<Object, Object> data = new HashMap<>();
data.put("client_id", this.clientId);
data.put("scope", "openid");
data.put("client_secret", this.clientSecret);
data.put("grant_type", "client_credentials");

String form =
data.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.reduce((entry1, entry2) -> entry1 + "&" + entry2)
.orElse("");
log.info(form);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();

CompletableFuture<HttpResponse<String>> responseFuture;
responseFuture = client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
String responseBody = responseFuture.join().body();
Object accessToken;
try {
log.info("responseBody " + responseBody);
accessToken = JsonSerialization.readValue(responseBody, Map.class).get("access_token");
log.info("authenticate " + accessToken.toString());
return accessToken.toString();
} catch (IOException e) {
e.printStackTrace();
return ClientCredentialsTokenClient.requestAccessToken(
client, url, this.clientId, this.clientSecret);
} catch (IOException | IllegalStateException e) {
throw new RuntimeException("Failed to retrieve Keycloak access token", e);
}
Comment on lines 319 to 324
return responseBody;
}

private String getTenantRealmName(String tenantId) {
Expand Down
59 changes: 59 additions & 0 deletions packages/keycloak-extensions/oauth2-client/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>

<!--
SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>

SPDX-License-Identifier: AGPL-3.0-only
-->

<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>

<parent>
<groupId>sequent</groupId>
<artifactId>keycloak-extensions</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>oauth2-client</artifactId>

<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.13.4</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.43.0</version>
<configuration>
<java>
<googleJavaFormat>
<version>1.23.0</version>
<style>GOOGLE</style>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins>
</build>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>
//
// SPDX-License-Identifier: AGPL-3.0-only

package sequent.keycloak.oauth2;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.keycloak.util.JsonSerialization;

/**
* Shared client for the OAuth2 client_credentials grant against a Keycloak realm's token endpoint.
* Extracted so the request-encoding and response-parsing logic exists in exactly one place; see
* meta#12526 for the incident (an unencoded client secret containing '%' broke
* application/x-www-form-urlencoded decoding) that motivated pulling this out of two separately
* copy-pasted implementations.
*/
public final class ClientCredentialsTokenClient {

private ClientCredentialsTokenClient() {}

public static String requestAccessToken(
HttpClient httpClient, String tokenUrl, String clientId, String clientSecret)
throws IOException {
Map<Object, Object> data = new HashMap<>();
data.put("client_id", clientId);
data.put("scope", "openid");
data.put("client_secret", clientSecret);
data.put("grant_type", "client_credentials");

HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(tokenUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(formUrlEncode(data)))
.build();

try {
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return extractAccessToken(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while requesting Keycloak access token", e);
}
}

// Percent-encodes each key/value pair so that secrets containing reserved
// form-encoding characters (%, &, +, =, space) survive as a valid
// application/x-www-form-urlencoded body.
static String formUrlEncode(Map<Object, Object> data) {
return data.entrySet().stream()
.map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
.collect(Collectors.joining("&"));
}

private static String encode(Object value) {
return URLEncoder.encode(String.valueOf(value), StandardCharsets.UTF_8);
}

// Fails loudly instead of NPE-ing on ".toString()" or silently returning the
// raw error body as if it were a token.
static String extractAccessToken(String responseBody) throws IOException {
Object accessToken = JsonSerialization.readValue(responseBody, Map.class).get("access_token");
if (accessToken == null) {
throw new IllegalStateException(
"Keycloak client_credentials response did not include an access_token: " + responseBody);
}
return accessToken.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>
//
// SPDX-License-Identifier: AGPL-3.0-only

package sequent.keycloak.oauth2;

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

import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;

class ClientCredentialsTokenClientTest {

@Test
void formUrlEncodeSurvivesReservedFormCharacters() {
// Regression test for meta#12526: a secret containing '%' broke
// application/x-www-form-urlencoded decoding on the token request.
Comment thread
Copilot marked this conversation as resolved.
Map<Object, Object> data = new HashMap<>();
data.put("client_id", "service-account");
data.put("client_secret", "ab%12&cd+ef=gh ij");
data.put("grant_type", "client_credentials");

String form = ClientCredentialsTokenClient.formUrlEncode(data);

Map<String, String> decoded = new HashMap<>();
for (String pair : form.split("&")) {
String[] kv = pair.split("=", 2);
decoded.put(
URLDecoder.decode(kv[0], StandardCharsets.UTF_8),
URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}

assertEquals("service-account", decoded.get("client_id"));
assertEquals("ab%12&cd+ef=gh ij", decoded.get("client_secret"));
assertEquals("client_credentials", decoded.get("grant_type"));
}

@Test
void extractAccessTokenReturnsTokenOnSuccess() throws IOException {
String responseBody = "{\"access_token\":\"the-token\",\"token_type\":\"Bearer\"}";

String token = ClientCredentialsTokenClient.extractAccessToken(responseBody);

assertEquals("the-token", token);
}

@Test
void extractAccessTokenThrowsClearErrorWhenTokenMissing() {
// This is the exact failure mode from the incident: Keycloak's token
// endpoint rejects a malformed request and returns an error body with no
// access_token. Previously this caused a NullPointerException.
String responseBody = "{\"error\":\"invalid_client\",\"error_description\":\"bad secret\"}";

IllegalStateException ex =
assertThrows(
IllegalStateException.class,
() -> ClientCredentialsTokenClient.extractAccessToken(responseBody));
assertTrue(ex.getMessage().contains("invalid_client"));
}

@Test
void extractAccessTokenThrowsOnMalformedJson() {
assertThrows(
IOException.class, () -> ClientCredentialsTokenClient.extractAccessToken("not json"));
}
}
4 changes: 4 additions & 0 deletions packages/keycloak-extensions/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only

<modules>
<module>aws-ses-email-sender-provider</module>
<module>oauth2-client</module>
<module>conditional-authenticators</module>
Comment on lines 19 to 22
<module>ivr-config-provider</module>
<module>dummy-email-sender-provider</module>
Expand Down Expand Up @@ -129,6 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<include>software.amazon.awssdk:*</include>
<include>org.reactivestreams:reactive-streams</include>
<include>com.rabbitmq:*</include>
<include>sequent:oauth2-client</include>
</includes>
</artifactSet>
</configuration>
Expand All @@ -146,6 +148,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<projectsDirectory>${basedir}</projectsDirectory>
<includes>
<include>aws-ses-email-sender-provider/pom.xml</include>
<include>oauth2-client/pom.xml</include>
<include>conditional-authenticators/pom.xml</include>
<include>ivr-config-provider/pom.xml</include>
<include>dummy-email-sender-provider/pom.xml</include>
Expand All @@ -163,6 +166,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<projectsDirectory>${basedir}</projectsDirectory>
<includes>
<include>aws-ses-email-sender-provider/pom.xml</include>
<include>oauth2-client/pom.xml</include>
<include>conditional-authenticators/pom.xml</include>
<include>ivr-config-provider/pom.xml</include>
<include>dummy-email-sender-provider/pom.xml</include>
Expand Down
5 changes: 5 additions & 0 deletions packages/keycloak-extensions/voter-enrollment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<artifactId>message-otp-authenticator</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>sequent</groupId>
<artifactId>oauth2-client</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
Loading
Loading