diff --git a/config/config/src/main/java/io/helidon/config/DeprecatedConfig.java b/config/config/src/main/java/io/helidon/config/DeprecatedConfig.java
index e25068bc348..91951297e74 100644
--- a/config/config/src/main/java/io/helidon/config/DeprecatedConfig.java
+++ b/config/config/src/main/java/io/helidon/config/DeprecatedConfig.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2020, 2022 Oracle and/or its affiliates.
+ * Copyright (c) 2020, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -61,4 +61,38 @@ public static Config get(Config config, String currentKey, String deprecatedKey)
return currentConfig;
}
}
+
+ /**
+ * Get a value from config, attempting to read both the keys.
+ * Warning is logged if either the current key is not defined, or both the keys are defined.
+ *
+ * @param config configuration instance
+ * @param currentKey key that should be used
+ * @param deprecatedKey key that should not be used
+ *
+ * @return config node of the current key if exists, of the deprecated key if it does not, missing node otherwise
+ */
+ public static io.helidon.common.config.Config get(io.helidon.common.config.Config config,
+ String currentKey,
+ String deprecatedKey) {
+ io.helidon.common.config.Config deprecatedConfig = config.get(deprecatedKey);
+ io.helidon.common.config.Config currentConfig = config.get(currentKey);
+
+ if (deprecatedConfig.exists()) {
+ if (currentConfig.exists()) {
+ LOGGER.log(Level.WARNING, "You are using both a deprecated configuration and a current one. "
+ + "Deprecated key: \"" + deprecatedConfig.key() + "\", "
+ + "current key: \"" + currentConfig.key() + "\", "
+ + "only the current key will be used, and deprecated will be ignored.");
+ return currentConfig;
+ } else {
+ LOGGER.log(Level.WARNING, "You are using a deprecated configuration key. "
+ + "Deprecated key: \"" + deprecatedConfig.key() + "\", "
+ + "current key: \"" + currentConfig.key() + "\".");
+ return deprecatedConfig;
+ }
+ } else {
+ return currentConfig;
+ }
+ }
}
diff --git a/security/providers/idcs-mapper/src/main/java/io/helidon/security/providers/idcs/mapper/IdcsRoleMapperProviderBase.java b/security/providers/idcs-mapper/src/main/java/io/helidon/security/providers/idcs/mapper/IdcsRoleMapperProviderBase.java
index 4c2ccbbb5fc..66d90e57841 100644
--- a/security/providers/idcs-mapper/src/main/java/io/helidon/security/providers/idcs/mapper/IdcsRoleMapperProviderBase.java
+++ b/security/providers/idcs-mapper/src/main/java/io/helidon/security/providers/idcs/mapper/IdcsRoleMapperProviderBase.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021, 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2021, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -122,7 +122,8 @@ public AuthenticationResponse map(ProviderRequest authenticatedRequest, Authenti
// create a new response
AuthenticationResponse.Builder builder = AuthenticationResponse.builder()
- .requestHeaders(previousResponse.requestHeaders());
+ .requestHeaders(previousResponse.requestHeaders())
+ .responseHeaders(previousResponse.responseHeaders());
previousResponse.description().ifPresent(builder::description);
if (maybeUser.isPresent()) {
diff --git a/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/BaseBuilder.java b/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/BaseBuilder.java
index 6e820aeb34f..006964b93b4 100644
--- a/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/BaseBuilder.java
+++ b/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/BaseBuilder.java
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 Oracle and/or its affiliates.
+ * Copyright (c) 2023, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
import io.helidon.common.Errors;
import io.helidon.common.config.Config;
import io.helidon.common.configurable.Resource;
+import io.helidon.config.DeprecatedConfig;
import io.helidon.config.metadata.Configured;
import io.helidon.config.metadata.ConfiguredOption;
import io.helidon.security.jwt.jwk.JwkKeys;
@@ -119,7 +120,8 @@ public B config(Config config) {
config.get("sign-jwk.resource").map(Resource::create).ifPresent(this::signJwk);
config.get("introspect-endpoint-uri").as(URI.class).ifPresent(this::introspectEndpointUri);
- config.get("validate-with-jwk").asBoolean().ifPresent(this::validateJwtWithJwk);
+ DeprecatedConfig.get(config, "validate-jwt-with-jwk", "validate-with-jwk")
+ .asBoolean().ifPresent(this::validateJwtWithJwk);
config.get("issuer").asString().ifPresent(this::issuer);
config.get("audience").asString().ifPresent(this::audience);
diff --git a/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/OidcConfig.java b/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/OidcConfig.java
index 130bcea45d7..9a2df5ede48 100644
--- a/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/OidcConfig.java
+++ b/security/providers/oidc-common/src/main/java/io/helidon/security/providers/oidc/common/OidcConfig.java
@@ -210,7 +210,7 @@
*
URI of an authorization endpoint used to redirect users to for logging-in. |
*
*
- * validate-with-jwk |
+ * validate-jwt-with-jwk |
* true |
* When true - validate against jwk defined by "sign-jwk", when false
* validate JWT through OIDC Server endpoint "validation-endpoint-uri" |
@@ -225,7 +225,7 @@
*
* introspect-endpoint-uri |
* "introspection_endpoint" in OIDC metadata, or identity-uri/oauth2/v1/introspect |
- * When validate-with-jwk is set to "false", this is the endpoint used |
+ * When validate-jwt-with-jwk is set to "false", this is the endpoint used |
*
*
* base-scopes |
@@ -401,6 +401,7 @@ public final class OidcConfig extends TenantConfigImpl {
private final OidcCookieHandler stateCookieHandler;
private final boolean tokenSignatureValidation;
private final boolean idTokenSignatureValidation;
+ private final boolean accessTokenIpCheck;
private OidcConfig(Builder builder) {
super(builder);
@@ -433,6 +434,7 @@ private OidcConfig(Builder builder) {
this.stateCookieHandler = builder.stateCookieBuilder.build();
this.tokenSignatureValidation = builder.tokenSignatureValidation;
this.idTokenSignatureValidation = builder.idTokenSignatureValidation;
+ this.accessTokenIpCheck = builder.accessTokenIpCheck;
this.webClientBuilderSupplier = builder.webClientBuilderSupplier;
this.defaultTenant = LazyValue.create(() -> Tenant.create(this, this));
@@ -816,6 +818,15 @@ public boolean idTokenSignatureValidation() {
return idTokenSignatureValidation;
}
+ /**
+ * Whether to check IP address access token was issued for.
+ *
+ * @return whether to check IP address access token was issued for
+ */
+ public boolean accessTokenIpCheck() {
+ return accessTokenIpCheck;
+ }
+
Supplier webClientBuilderSupplier() {
return webClientBuilderSupplier;
}
@@ -946,6 +957,7 @@ public static class Builder extends BaseBuilder {
private boolean relativeUris = DEFAULT_RELATIVE_URIS;
private boolean tokenSignatureValidation = true;
private boolean idTokenSignatureValidation = true;
+ private boolean accessTokenIpCheck = true;
protected Builder() {
}
@@ -1070,6 +1082,7 @@ public Builder config(Config config) {
config.get("token-signature-validation").asBoolean().ifPresent(this::tokenSignatureValidation);
config.get("id-token-signature-validation").asBoolean().ifPresent(this::idTokenSignatureValidation);
+ config.get("access-token-ip-check").asBoolean().ifPresent(this::accessTokenIpCheck);
config.get("tenants").asList(Config.class)
.ifPresent(confList -> confList.forEach(tenantConfig -> tenantFromConfig(config, tenantConfig)));
@@ -1720,5 +1733,18 @@ public Builder idTokenSignatureValidation(boolean enabled) {
return this;
}
+ /**
+ * Whether to check if current IP address matches the one access token was issued for.
+ * This check helps with cookie replay attack prevention.
+ *
+ * @param enabled whether to check if current IP address matches the one access token was issued for
+ * @return updated builder instance
+ */
+ @ConfiguredOption("true")
+ public Builder accessTokenIpCheck(boolean enabled) {
+ accessTokenIpCheck = enabled;
+ return this;
+ }
+
}
}
diff --git a/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcFeature.java b/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcFeature.java
index b09fcab8688..025c9f2108e 100644
--- a/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcFeature.java
+++ b/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcFeature.java
@@ -67,6 +67,7 @@
import io.helidon.webserver.security.SecurityHttpFeature;
import jakarta.json.Json;
+import jakarta.json.JsonBuilderFactory;
import jakarta.json.JsonObject;
import jakarta.json.JsonReaderFactory;
@@ -147,11 +148,12 @@
*/
@Weight(800)
public final class OidcFeature implements HttpFeature {
+ static final JsonReaderFactory JSON_READER_FACTORY = Json.createReaderFactory(Map.of());
+ static final JsonBuilderFactory JSON_BUILDER_FACTORY = Json.createBuilderFactory(Map.of());
private static final System.Logger LOGGER = System.getLogger(OidcFeature.class.getName());
private static final String CODE_PARAM_NAME = "code";
private static final String STATE_PARAM_NAME = "state";
private static final String DEFAULT_REDIRECT = "/index.html";
- private static final JsonReaderFactory JSON = Json.createReaderFactory(Map.of());
private final List oidcConfigFinders;
private final LruCache tenants = LruCache.create();
@@ -391,7 +393,7 @@ private void processCodeWithTenant(String code, ServerRequest req, ServerRespons
return;
}
String stateBase64 = new String(Base64.getDecoder().decode(maybeStateCookie.get()), StandardCharsets.UTF_8);
- JsonObject stateCookie = JSON.createReader(new StringReader(stateBase64)).readObject();
+ JsonObject stateCookie = JSON_READER_FACTORY.createReader(new StringReader(stateBase64)).readObject();
//Remove state cookie
res.headers().addCookie(stateCookieHandler.removeCookie().build());
String state = stateCookie.getString("state");
@@ -422,7 +424,7 @@ private void processCodeWithTenant(String code, ServerRequest req, ServerRespons
if (response.status().family() == Status.Family.SUCCESSFUL) {
try {
JsonObject jsonObject = response.as(JsonObject.class);
- processJsonResponse(res, jsonObject, tenantName, stateCookie);
+ processJsonResponse(req, res, jsonObject, tenantName, stateCookie);
} catch (Exception e) {
processError(res, e, "Failed to read JSON from response");
}
@@ -478,7 +480,8 @@ private String redirectUri(ServerRequest req, String tenantName) {
return uri;
}
- private String processJsonResponse(ServerResponse res,
+ private String processJsonResponse(ServerRequest req,
+ ServerResponse res,
JsonObject json,
String tenantName,
JsonObject stateCookie) {
@@ -486,12 +489,12 @@ private String processJsonResponse(ServerResponse res,
String idToken = json.getString("id_token", null);
String refreshToken = json.getString("refresh_token", null);
- Jwt accessTokenJwt = SignedJwt.parseToken(accessToken).getJwt();
+ Jwt idTokenJwt = SignedJwt.parseToken(idToken).getJwt();
String nonceOriginal = stateCookie.getString("nonce");
- String nonceAccess = accessTokenJwt.nonce()
- .orElseThrow(() -> new IllegalStateException("Nonce is required to be present in the access token"));
+ String nonceAccess = idTokenJwt.nonce()
+ .orElseThrow(() -> new IllegalStateException("Nonce is required to be present in the id token"));
if (!nonceAccess.equals(nonceOriginal)) {
- throw new IllegalStateException("Original nonce and the one obtained from access token does not match");
+ throw new IllegalStateException("Original nonce and the one obtained from id token does not match");
}
//redirect to "originalUri"
@@ -512,12 +515,19 @@ private String processJsonResponse(ServerResponse res,
if (oidcConfig.useCookie()) {
try {
+ JsonObject accessTokenJson = JSON_BUILDER_FACTORY.createObjectBuilder()
+ .add("accessToken", accessToken)
+ .add("remotePeer", req.remotePeer().host())
+ .build();
+ String encodedAccessToken = Base64.getEncoder()
+ .encodeToString(accessTokenJson.toString().getBytes(StandardCharsets.UTF_8));
+
ServerResponseHeaders headers = res.headers();
OidcCookieHandler tenantCookieHandler = oidcConfig.tenantCookieHandler();
headers.addCookie(tenantCookieHandler.createCookie(tenantName).build()); //Add tenant name cookie
- headers.addCookie(tokenCookieHandler.createCookie(accessToken).build()); //Add token cookie
+ headers.addCookie(tokenCookieHandler.createCookie(encodedAccessToken).build()); //Add token cookie
if (refreshToken != null) {
headers.addCookie(refreshTokenCookieHandler.createCookie(refreshToken).build()); //Add refresh token cookie
}
diff --git a/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/TenantAuthenticationHandler.java b/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/TenantAuthenticationHandler.java
index 2822fafc626..3b3b04c69f6 100644
--- a/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/TenantAuthenticationHandler.java
+++ b/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/TenantAuthenticationHandler.java
@@ -16,6 +16,7 @@
package io.helidon.security.providers.oidc;
+import java.io.StringReader;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
@@ -68,10 +69,10 @@
import io.helidon.webclient.api.HttpClientResponse;
import io.helidon.webclient.api.WebClient;
-import jakarta.json.Json;
-import jakarta.json.JsonBuilderFactory;
import jakarta.json.JsonObject;
+import static io.helidon.security.providers.oidc.OidcFeature.JSON_BUILDER_FACTORY;
+import static io.helidon.security.providers.oidc.OidcFeature.JSON_READER_FACTORY;
import static io.helidon.security.providers.oidc.common.spi.TenantConfigFinder.DEFAULT_TENANT_ID;
/**
@@ -81,7 +82,6 @@ class TenantAuthenticationHandler {
private static final System.Logger LOGGER = System.getLogger(TenantAuthenticationHandler.class.getName());
private static final TokenHandler PARAM_HEADER_HANDLER = TokenHandler.forHeader(OidcConfig.PARAM_HEADER_NAME);
private static final TokenHandler PARAM_ID_HEADER_HANDLER = TokenHandler.forHeader(OidcConfig.PARAM_ID_HEADER_NAME);
- private static final JsonBuilderFactory JSON = Json.createBuilderFactory(Map.of());
private static final SecureRandom RANDOM = new SecureRandom();
private final boolean optional;
@@ -264,7 +264,23 @@ private AuthenticationResponse processAccessToken(String tenantId, ProviderReque
} else {
try {
String tokenValue = cookie.get();
- return validateAccessToken(tenantId, providerRequest, tokenValue, idToken);
+ String decodedJson = new String(Base64.getDecoder().decode(tokenValue), StandardCharsets.UTF_8);
+ JsonObject jsonObject = JSON_READER_FACTORY.createReader(new StringReader(decodedJson)).readObject();
+ if (oidcConfig.accessTokenIpCheck()) {
+ Object userIp = providerRequest.env().abacAttribute("userIp").orElseThrow();
+ if (!jsonObject.getString("remotePeer").equals(userIp)) {
+ if (LOGGER.isLoggable(System.Logger.Level.DEBUG)) {
+ LOGGER.log(System.Logger.Level.DEBUG,
+ "Current peer IP does not match the one this access token was issued for");
+ }
+ return errorResponse(providerRequest,
+ Status.UNAUTHORIZED_401,
+ "peer_host_mismatch",
+ "Peer host access token mismatch",
+ tenantId);
+ }
+ }
+ return validateAccessToken(tenantId, providerRequest, jsonObject.getString("accessToken"), idToken);
} catch (Exception e) {
if (LOGGER.isLoggable(System.Logger.Level.DEBUG)) {
LOGGER.log(System.Logger.Level.DEBUG, "Invalid access token in cookie", e);
@@ -372,7 +388,7 @@ private AuthenticationResponse errorResponse(ProviderRequest providerRequest,
+ "nonce=" + nonce + "&"
+ "state=" + state;
- JsonObject stateJson = JSON.createObjectBuilder()
+ JsonObject stateJson = JSON_BUILDER_FACTORY.createObjectBuilder()
.add("originalUri", origUri)
.add("state", state)
.add("nonce", nonce)
@@ -575,8 +591,7 @@ private AuthenticationResponse refreshAccessToken(ProviderRequest providerReques
Parameters.Builder form = Parameters.builder("oidc-form-params")
.add("grant_type", "refresh_token")
.add("refresh_token", refreshTokenString)
- .add("client_id", tenantConfig.clientId())
- .add("client_secret", tenantConfig.clientSecret());
+ .add("client_id", tenantConfig.clientId());
HttpClientRequest post = webClient.post()
.uri(tenant.tokenEndpointUri())
@@ -600,10 +615,18 @@ private AuthenticationResponse refreshAccessToken(ProviderRequest providerReques
return AuthenticationResponse.failed("Invalid access token", e);
}
Errors.Collector newAccessTokenCollector = jwtValidator.apply(signedAccessToken, Errors.collector());
+ Object remotePeer = providerRequest.env().abacAttribute("userIp").orElseThrow();
+
+ JsonObject accessTokenCookie = JSON_BUILDER_FACTORY.createObjectBuilder()
+ .add("accessToken", signedAccessToken.tokenContent())
+ .add("remotePeer", remotePeer.toString())
+ .build();
+ String base64 = Base64.getEncoder()
+ .encodeToString(accessTokenCookie.toString().getBytes(StandardCharsets.UTF_8));
List setCookieParts = new ArrayList<>();
setCookieParts.add(oidcConfig.tokenCookieHandler()
- .createCookie(accessToken)
+ .createCookie(base64)
.build()
.toString());
if (refreshToken != null) {
diff --git a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/CommonLoginBase.java b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/CommonLoginBase.java
index 538fc496d29..66c62d30ae4 100644
--- a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/CommonLoginBase.java
+++ b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/CommonLoginBase.java
@@ -16,6 +16,9 @@
package io.helidon.tests.integration.oidc;
+import java.util.List;
+import java.util.Map;
+
import io.helidon.config.Config;
import io.helidon.jersey.connector.HelidonConnectorProvider;
import io.helidon.jersey.connector.HelidonProperties;
@@ -23,8 +26,16 @@
import io.helidon.microprofile.testing.junit5.HelidonTest;
import dasniko.testcontainers.keycloak.KeycloakContainer;
+import jakarta.json.Json;
+import jakarta.json.JsonBuilderFactory;
+import jakarta.json.JsonReaderFactory;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
import org.jsoup.Jsoup;
@@ -35,11 +46,22 @@
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
+import static io.helidon.security.providers.oidc.common.OidcConfig.DEFAULT_ID_COOKIE_NAME;
+import static org.hamcrest.CoreMatchers.hasItem;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.CoreMatchers.startsWith;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+
@Testcontainers
@HelidonTest(resetPerTest = true)
@AddBean(TestResource.class)
class CommonLoginBase {
+ static final JsonBuilderFactory JSON_OBJECT_BUILDER_FACTORY = Json.createBuilderFactory(Map.of());
+ static final JsonReaderFactory JSON_READER_FACTORY = Json.createReaderFactory(Map.of());
+
@Container
static final KeycloakContainer KEYCLOAK_CONTAINER = new KeycloakContainer()
.withRealmImportFiles("/test-realm.json", "/test2-realm.json")
@@ -79,4 +101,48 @@ String getRequestUri(String html) {
return document.getElementById("kc-form-login").attr("action");
}
+ List obtainCookies(WebTarget webTarget) {
+ String formUri;
+
+ //greet endpoint is protected, and we need to get JWT token out of the Keycloak. We will get redirected to the Keycloak.
+ try (Response response = client.target(webTarget.getUri())
+ .path("/test")
+ .request()
+ .get()) {
+ assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode()));
+ //We need to get form URI out of the HTML
+ formUri = getRequestUri(response.readEntity(String.class));
+ }
+
+ String redirectHelidonUrl;
+ //Sending authentication to the Keycloak and getting redirected back to the running Helidon app.
+ //Redirection needs to be disabled, so we can get Set-Cookie header from Helidon redirect endpoint
+ Entity