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
form = Entity.form(new Form().param("username", "userone") + .param("password", "12345") + .param("credentialId", "")); + try (Response response = client.target(formUri) + .request() + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .header("Connection", "close") + .post(form)) { + assertThat(response.getStatus(), is(Response.Status.FOUND.getStatusCode())); + redirectHelidonUrl = response.getStringHeaders().getFirst(HttpHeaders.LOCATION); + } + + List setCookies; + //Helidon OIDC redirect endpoint -> Sends back Set-Cookie header + try (Response response = client.target(redirectHelidonUrl) + .request() + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .get()) { + assertThat(response.getStatus(), is(Response.Status.TEMPORARY_REDIRECT.getStatusCode())); + //Since invalid access token has been provided, this means that the new one has been obtained + setCookies = response.getStringHeaders().get(HttpHeaders.SET_COOKIE); + assertThat(setCookies, not(empty())); + assertThat(setCookies, hasItem(startsWith(DEFAULT_ID_COOKIE_NAME))); + } + + return setCookies; + } + } diff --git a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/IdTokenIT.java b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/IdTokenIT.java index 095a6467f62..5135c58f879 100644 --- a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/IdTokenIT.java +++ b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/IdTokenIT.java @@ -131,48 +131,4 @@ public void testAuthenticationWithExpiredIdToken(WebTarget webTarget) { } - private 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 form = Entity.form(new Form().param("username", "userone") - .param("password", "12345") - .param("credentialId", "")); - try (Response response = client.target(formUri) - .request() - .property(ClientProperties.FOLLOW_REDIRECTS, false) - .header("Connection", "close") - .post(form)) { - assertThat(response.getStatus(), is(Response.Status.FOUND.getStatusCode())); - redirectHelidonUrl = response.getStringHeaders().getFirst(HttpHeaders.LOCATION); - } - - List setCookies; - //Helidon OIDC redirect endpoint -> Sends back Set-Cookie header - try (Response response = client.target(redirectHelidonUrl) - .request() - .property(ClientProperties.FOLLOW_REDIRECTS, false) - .get()) { - assertThat(response.getStatus(), is(Response.Status.TEMPORARY_REDIRECT.getStatusCode())); - //Since invalid access token has been provided, this means that the new one has been obtained - setCookies = response.getStringHeaders().get(HttpHeaders.SET_COOKIE); - assertThat(setCookies, not(empty())); - assertThat(setCookies, hasItem(startsWith(DEFAULT_ID_COOKIE_NAME))); - } - - return setCookies; - } - } diff --git a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/RefreshTokenIT.java b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/RefreshTokenIT.java index 0913bfa78a1..2d06ef0fa0d 100644 --- a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/RefreshTokenIT.java +++ b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/RefreshTokenIT.java @@ -16,7 +16,9 @@ package io.helidon.tests.integration.oidc; +import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.Base64; import java.util.List; import io.helidon.microprofile.testing.junit5.AddConfig; @@ -25,6 +27,7 @@ import io.helidon.security.jwt.jwk.Jwk; import io.helidon.security.providers.oidc.common.OidcConfig; +import jakarta.json.JsonObject; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.Form; @@ -86,12 +89,17 @@ public void testRefreshToken(WebTarget webTarget) { .notBefore(Instant.ofEpochMilli(1)) .build(); SignedJwt signedJwt = SignedJwt.sign(jwt, Jwk.NONE_JWK); + JsonObject jsonObject = JSON_OBJECT_BUILDER_FACTORY.createObjectBuilder() + .add("accessToken", signedJwt.tokenContent()) + .add("remotePeer", "127.0.0.1") + .build(); + String base64 = Base64.getEncoder().encodeToString(jsonObject.toString().getBytes(StandardCharsets.UTF_8)); try (Response response = client .target(webTarget.getUri()) .path("/test") .request() - .header(HttpHeaders.COOKIE, OidcConfig.DEFAULT_COOKIE_NAME + "=" + signedJwt.tokenContent()) + .header(HttpHeaders.COOKIE, OidcConfig.DEFAULT_COOKIE_NAME + "=" + base64) .get()) { assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); assertThat(response.readEntity(String.class), is(EXPECTED_TEST_MESSAGE)); diff --git a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/SecurityPartsIT.java b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/SecurityPartsIT.java index c0b6e0f150b..ce9cac736a4 100644 --- a/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/SecurityPartsIT.java +++ b/tests/integration/oidc/src/test/java/io/helidon/tests/integration/oidc/SecurityPartsIT.java @@ -17,8 +17,9 @@ package io.helidon.tests.integration.oidc; import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Map; +import java.util.List; import io.helidon.config.Config; import io.helidon.config.ConfigSources; @@ -27,13 +28,11 @@ import io.helidon.microprofile.testing.junit5.AddConfig; import io.helidon.security.providers.oidc.common.OidcConfig; -import jakarta.json.Json; -import jakarta.json.JsonBuilderFactory; import jakarta.json.JsonObject; -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.Invocation; import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.HttpHeaders; @@ -44,15 +43,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.hamcrest.CoreMatchers.hasItem; +import static io.helidon.security.providers.oidc.common.OidcConfig.DEFAULT_COOKIE_NAME; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; class SecurityPartsIT extends CommonLoginBase { - private static final JsonBuilderFactory JSON_OBJECT_BUILDER_FACTORY = Json.createBuilderFactory(Map.of()); - private static final JsonReaderFactory JSON_READER_FACTORY = Json.createReaderFactory(Map.of()); private static final ClientConfig CONFIG_CLIENT_2 = new ClientConfig() .connectorProvider(new HelidonConnectorProvider()) @@ -241,4 +238,67 @@ public void testNotMatchingNonce(WebTarget webTarget) { } } + @Test + @AddConfig(key = "security.providers.1.oidc.cookie-encryption-enabled", value = "false") + public void testAccessTokenIssuedIp(WebTarget webTarget) { + List setCookies = obtainCookies(webTarget); + + //Ignore ID token cookie + Invocation.Builder request = client2.target(webTarget.getUri()).path("/test").request(); + for (String setCookie : setCookies) { + if (!setCookie.startsWith(DEFAULT_COOKIE_NAME + "=")) { + request.header(HttpHeaders.COOKIE, setCookie); + } else { + String encodedJson = setCookie.substring(setCookie.indexOf("=") + 1, setCookie.indexOf(";")); + String json = new String(Base64.getDecoder().decode(encodedJson), StandardCharsets.UTF_8); + JsonObject jsonObject = JSON_READER_FACTORY.createReader(new StringReader(json)).readObject(); + JsonObject recreatedJsonObject = JSON_OBJECT_BUILDER_FACTORY.createObjectBuilder(jsonObject) + .add("remotePeer", "1.1.1.1") //some other address than localhost + .build(); + String base64 = Base64.getEncoder() + .encodeToString(recreatedJsonObject.toString().getBytes(StandardCharsets.UTF_8)); + request.header(HttpHeaders.COOKIE, DEFAULT_COOKIE_NAME + "=" + base64); + } + } + + try (Response response = request + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .get()) { + //This means, remote peer was not the same as our IP. We are getting redirected to key cloak again + assertThat(response.getStatus(), is(Response.Status.TEMPORARY_REDIRECT.getStatusCode())); + } + } + + @Test + @AddConfig(key = "security.providers.1.oidc.cookie-encryption-enabled", value = "false") + @AddConfig(key = "security.providers.1.oidc.access-token-ip-check", value = "false") + public void testDisabledAccessTokenIssuedIp(WebTarget webTarget) { + List setCookies = obtainCookies(webTarget); + + //Ignore ID token cookie + Invocation.Builder request = client2.target(webTarget.getUri()).path("/test").request(); + for (String setCookie : setCookies) { + if (!setCookie.startsWith(DEFAULT_COOKIE_NAME + "=")) { + request.header(HttpHeaders.COOKIE, setCookie); + } else { + String encodedJson = setCookie.substring(setCookie.indexOf("=") + 1, setCookie.indexOf(";")); + String json = new String(Base64.getDecoder().decode(encodedJson), StandardCharsets.UTF_8); + JsonObject jsonObject = JSON_READER_FACTORY.createReader(new StringReader(json)).readObject(); + JsonObject recreatedJsonObject = JSON_OBJECT_BUILDER_FACTORY.createObjectBuilder(jsonObject) + .add("remotePeer", "1.1.1.1") //some other address than localhost + .build(); + String base64 = Base64.getEncoder() + .encodeToString(recreatedJsonObject.toString().getBytes(StandardCharsets.UTF_8)); + request.header(HttpHeaders.COOKIE, DEFAULT_COOKIE_NAME + "=" + base64); + } + } + + try (Response response = request + .property(ClientProperties.FOLLOW_REDIRECTS, false) + .get()) { + //This means, remote peer was not the same as our IP. We are getting redirected to key cloak again + assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode())); + } + } + }