Using Identity-Aware-Proxy and JAX-RS to authenticate users
By deploying a web application behind Identity-Aware-Proxy (IAP), we can ensure that an application only receives requests that are authenticated and satisfy the context-aware access rules we’ve configured. In zero-trust lingo, that means IAP is a policy enforcement point.
But there are still a few things that the web application needs to do itself.
In a previous post,
we looked at how IAP works, and saw that IAP injects a special X-Goog-Iap-Jwt-Assertion
header into each request that it passes to the application:
This header contains an IAP JWT assertion that looks a bit like an IdToken, but is not an IdToken.
The primary purpose of an IdToken is to enable a relying party to identify the user. In contrast, the primary purpose of the IAP JWT assertion is to enable the backend application to verify that the request has been properly vetted by Cloud IAP.
But is it really necessary that a web application verifies the IAP JWT assertion for each request? Let’s revisit the threat vectors related to IAP:
Accidental disabling of Cloud IAP: You might accidentally disable Cloud IAP, causing all requests to be passed unvetted.
To protect against this threat, an application should check for the existence of the JWT assertion.
Sidestepping Cloud IAP: If firewall rules are not properly configured, a malicious client might sidestep Cloud IAP and send a request directly to the backend application. To overcome the check for the assertion, it might send a fake assertion:
To protect against sidestepping and fake assertions, a backend application should verify the signature of the JWT against Cloud IAP’s JWKS.
Because IAP JWT assertions are JWTs, any JWT-compliant OAuth or OpenID Connect library should be able to perform such validation.
Replay: A malicious client that somehow got hold of an IAP JWT assertion might keep replaying the assertion.
To protect against such replays, a backend application should verify the expiration of the assertion as encoded in the
exp
claim.Assertions from a different IAP instance: Finally, it is possible that the request passed some Cloud IAP instance, but not the right one. This is a rare case and probably requires some configuration screw-up to happen, but still… To verify that an IAP JWT assertion is indeed intended for this backend application and not for some other party, a backend application should verify the audience claim (
aud
).
So yes, there are good reasons why an application should verify the IAP JWT assertion that IAP injects into request headers. Now let’s see how we can do this for in a Java application that uses JAX-RS.
Verifying IAP assertions using a JAX-RS ContainerRequestFilter
JAX-RS lets us intercept and filter requests by creating a custom
ContainerRequestFilter
. So let’s create a filter that:
- reads the
X-Goog-Iap-Jwt-Assertion
header and verifies the assertion. If there’s anything wrong with the assertion, we fail the request with a HTTP 403 response. - extracts the user’s email address from the assertion and makes it available to the REST resources.
Instead of writing any custom JWT verification code, let’s use the Google Cloud client libraries:
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client</artifactId>
</dependency>
Using these libraries, we can now write a custom filter:
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.auth.oauth2.TokenVerifier;
import org.jboss.logging.Logger;
import javax.annotation.Priority;
import javax.enterprise.context.Dependent;
import javax.inject.Inject;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.ext.Provider;
import java.security.Principal;
/**
* Verifies that requests have a valid IAP assertion, and makes the assertion
* available as SecurityContext.
*/
@Dependent
@Provider
@Priority(Priorities.AUTHENTICATION)
public class IapRequestFilter implements ContainerRequestFilter
{
private static final String IAP_ISSUER_URL = "https://cloud.google.com/iap";
private static final String IAP_ASSERTION_HEADER = "x-goog-iap-jwt-assertion";
private static final Logger LOG = Logger.getLogger(IapRequestFilter.class);
@Inject
IapRequestFilterConfig config;
private Principal authenticateRequest(
ContainerRequestContext requestContext)
{
//
// Read IAP assertion header and validate it.
//
String assertion = requestContext.getHeaderString(IAP_ASSERTION_HEADER);
if (assertion == null)
{
throw new ForbiddenException("IAP assertion missing, application must be accessed via IAP");
}
try
{
final JsonWebToken jsonWebToken = TokenVerifier
.newBuilder()
.setAudience(this.config.getExpectedAudience())
.setIssuer(IAP_ISSUER_URL)
.build()
.verify(assertion);
//
// Associate the token with the request so that controllers
// can access it.
//
return new Principal()
{
@Override
public String getName()
{
return jsonWebToken.getPayload().get("email").toString();
}
};
}
catch (TokenVerifier.VerificationException | IllegalArgumentException e)
{
throw new ForbiddenException("Invalid IAP assertion", e);
}
}
public void filter(ContainerRequestContext requestContext)
{
Principal principal = authenticateRequest(requestContext);
requestContext.setSecurityContext(new SecurityContext()
{
@Override
public Principal getUserPrincipal()
{
return principal;
}
@Override
public boolean isUserInRole(String s)
{
return false;
}
@Override
public boolean isSecure()
{
return true;
}
@Override
public String getAuthenticationScheme()
{
return "IAP";
}
});
LOG.infof("Authenticated IAP principal: %s", principal.getName());
}
}
There is one piece of configuration that the IapRequestFilter
needs, and that’s
the expected audience. Typically, we’d read this information from a configuration
file or environment variable. But if our apps runs on AppEngine, we can determine
the expected audience automatically based on the project number and project ID:
/projects/PROJECT_NUMBER/apps/PROJECT_ID
Both the project number and project ID can be read from the metadata server.
Obtaining user information from the resource context
As IAP and our IapRequestFilter
take care of authentication, we don’t need to
add any extra authentication logic to our REST methods. But we might still need
to know who the authenticated user is. For that, we can inject a SecurityContext
and access the principal like so:
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.SecurityContext;
@Path("/api/")
public class ApiResource
{
@GET
@Produces(MediaType.TEXT_PLAIN)
public String whoami(@Context SecurityContext securityContext)
{
return securityContext.getUserPrincipal().getName();
}
}