Skip to content

Commit

Permalink
OIDC updates
Browse files Browse the repository at this point in the history
Signed-off-by: David Kral <[email protected]>
  • Loading branch information
Verdent committed Feb 15, 2024
1 parent ab60ba4 commit 84bce39
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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((io.helidon.config.Config) 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
* <td>URI of an authorization endpoint used to redirect users to for logging-in.</td>
* </tr>
* <tr>
* <td>validate-with-jwk</td>
* <td>validate-jwt-with-jwk</td>
* <td>true</td>
* <td>When true - validate against jwk defined by "sign-jwk", when false
* validate JWT through OIDC Server endpoint "validation-endpoint-uri"</td>
Expand All @@ -225,7 +225,7 @@
* <tr>
* <td>introspect-endpoint-uri</td>
* <td>"introspection_endpoint" in OIDC metadata, or identity-uri/oauth2/v1/introspect</td>
* <td>When validate-with-jwk is set to "false", this is the endpoint used</td>
* <td>When validate-jwt-with-jwk is set to "false", this is the endpoint used</td>
* </tr>
* <tr>
* <td>base-scopes</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -151,7 +152,8 @@ public final class OidcFeature implements HttpFeature {
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());
static final JsonReaderFactory JSON_READER_FACTORY = Json.createReaderFactory(Map.of());
static final JsonBuilderFactory JSON_BUILDER_FACTORY = Json.createBuilderFactory(Map.of());

private final List<TenantConfigFinder> oidcConfigFinders;
private final LruCache<String, Tenant> tenants = LruCache.create();
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -478,20 +480,21 @@ 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) {
String accessToken = json.getString("access_token");
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"
Expand All @@ -512,13 +515,22 @@ 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(refreshTokenCookieHandler.createCookie(refreshToken).build()); //Add refresh token cookie
headers.addCookie(tokenCookieHandler.createCookie(encodedAccessToken).build()); //Add token cookie
if (refreshToken != null) {
headers.addCookie(refreshTokenCookieHandler.createCookie(refreshToken).build()); //Add refresh token cookie
}

if (idToken != null) {
headers.addCookie(idTokenCookieHandler.createCookie(idToken).build()); //Add token id cookie
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

/**
Expand All @@ -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;
Expand Down Expand Up @@ -264,7 +264,21 @@ 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();
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);
Expand Down Expand Up @@ -372,7 +386,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)
Expand Down Expand Up @@ -575,8 +589,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())
Expand All @@ -600,10 +613,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<String> setCookieParts = new ArrayList<>();
setCookieParts.add(oidcConfig.tokenCookieHandler()
.createCookie(accessToken)
.createCookie(base64)
.build()
.toString());
if (refreshToken != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,26 @@

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;
import io.helidon.microprofile.testing.junit5.AddBean;
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;
Expand All @@ -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")
Expand Down Expand Up @@ -79,4 +101,48 @@ String getRequestUri(String html) {
return document.getElementById("kc-form-login").attr("action");
}

List<String> 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> 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<String> 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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -131,48 +131,4 @@ public void testAuthenticationWithExpiredIdToken(WebTarget webTarget) {

}

private List<String> 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> 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<String> 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;
}

}
Loading

0 comments on commit 84bce39

Please sign in to comment.