/images/resources/tutorials/writing-apis/testing-zero-trust-apis.png

Testing Zero Trust APIs

On this page

The article on implementing zero trust APIs describes the main steps involved in using OAuth 2.0 to secure APIs and microservices. End-to-end flows that involve APIs often also involve a web or mobile client and a user. The client runs a code flow to sign the user in and get an access token with which to call APIs:

API End to End Flow

For API developers, an end-to-end flow is not a productive way to implement integration tests. Instead the developer will want to run the API as the single component under development. This tutorial shows how to use mocking to enable a productive local setup, where API security is also verified as early as possible in the software pipeline.

Mocking OAuth Infrastructure

To test an OAuth secured API, you do not need to prove that other parts of the flow, such as user authentication, are working. Instead you can use mocking to get a JWT access token in a quicker way, and focus only on the API component's responsibilities. This must include API request authorization before allowing access to resources.

Integration Test Mock Flow

One way to enable this is to simply use a JOSE library to create JSON Web Keys. The private key can then be used to mint access tokens, and the public key can be exposed in a mock JSON Web Key Set (JWKS) endpoint. This results in a productive setup where the developer can verify both authorized and unauthorized use cases.

The Access Token Contract

The API developer must ensure that any mock access tokens have the same contract as real access tokens sent to that API. An example payload is provided below, which contains an issuer, audience, scopes and claims that would be equivalent to those for a deployed system:

json
12345678910
{
"iss": "https://localhost:8443/oauth/v2/oauth-anonymous",
"aud": "api.company.com",
"scope": "products",
"sub": "Alice",
"exp": 1682419727,
"jti": "_53Ofv7dknd9aPmM8mmB9Q",
"iat": 1682419127,
"nbf": 1682419007
}

Run the Example API

This code example uses an API implemented using the minimal Spark Java framework. The techniques used in the code example could also be applied to any other API technology stack. Get the code using the GitHub link at the top of this page. Ensure that a Java SDK is installed, and also the maven build tool. Then run the following command to build the API's code:

bash
1
mvn package -DskipTests

Next, run the following command to run the API locally on port 9090:

bash
1
java -jar target/zero-trust-api-example-3.0.0.jar

To simplify local infrastructure, the code example uses plain HTTP URLs, though of course HTTPS should be used for deployed environments. The API listens at http://localhost:9090 and downloads token signing public keys from a JWKS URI at http://localhost:8443/oauth/v2/oauth-anonymous/jwks. The following command can be used to call a secured endpoint of the API. This will return a 401 unauthorized error, since a valid JWT access token has not been provided:

bash
1
curl http://localhost:9090/api/products

During API startup, the SparkServerExample class configures API routes and security. The API's JWT validation is implemented by an OAuth filter that uses the jose4j security library. The server options supply the JWKS URI and also the issuer, audience and scope that are expected to be received in access tokens.

java
1234567891011121314151617181920
public SparkServerExample(ProductService productService, @Nullable ServerOptions options) throws ServletException {
ServerOptions appliedOptions = Objects.requireNonNullElseGet(options, ServerOptions::new);
port(appliedOptions.getPort());
init();
// Run the filter before any api/* route
Filter oauthFilter = toSparkFilter(new OAuthFilter(options));
before("/api", oauthFilter);
before("/api/", oauthFilter);
before("/api/*", oauthFilter);
// Set up the product service to respond to /products and /products/productId routes
path("/api", () ->
path("/products", () -> {
get("", new ListProductsRequestHandler(productService));
get("/:productId", new GetProductRequestHandler(productService));
})
);
}

The filter takes care of validating a JWT on every request, and returning a 401 error if a missing, invalid or expired JWT is received. Claims are then added to the current request object. Handler classes can then retrieve the claims and use them to apply authorization.

The API has an example business theme of selling products. The products available depend on the user's country code. If a user asks for all products, then the ListProductsRequestHandler class filters the list to those that match the user's country, as illustrated by this code. Similarly, if a client requests a single product that is not valid for the user's claims, a forbidden response is returned.

java
12345678910
public class ListProductsRequestHandler extends ProductRequestHandler {
{
@Override
public Object handle(Request request, Response response) {
JwtClaims claimsPrincipal = request.attribute(OAuthFilter.CLAIMS_PRINCIPAL);
String countryCode = claimsPrincipal.getStringClaimValue(CLAIM_NAME_COUNTRY);
return getProducts(countryCode);
}
}

Test the Example API

The example Java API includes a number of integration tests that use mock access tokens. To run the example's tests, ensure that the API is stopped and then run all tests with the following command:

bash
1
mvn test

Integration tests begin by creating an instance of a MockJwtIssuer class, which uses the jose4j library to create a keypair for testing. The private key can then be used to create and customize any tokens needed in integration tests, using code similar to the following:

java
123456789101112131415161718192021222324252627282930313233
public class MockJwtIssuer {
private static RsaJsonWebKey createKeyPair(String kid) {
RsaJsonWebKey signingKey = RsaJwkGenerator.generateJwk(2048);
signingKey.setKeyId(kid);
return signingKey;
}
public String getJwt(String subjectName, Map<String, String> claims, String issuer, String audience) {
JwtClaims jwtClaims = new JwtClaims();
jwtClaims.setSubject(subjectName);
jwtClaims.setIssuer(Objects.requireNonNull(issuer));
jwtClaims.setAudience(Objects.requireNonNull(audience));
jwtClaims.setExpirationTimeMinutesInTheFuture(10);
jwtClaims.setGeneratedJwtId();
jwtClaims.setIssuedAtToNow();
jwtClaims.setNotBeforeMinutesInThePast(2);
JsonWebSignature jws = new JsonWebSignature();
jws.setPayload(jwtClaims.toJson());
jws.setKey(SIGNING_KEY.getPrivateKey());
jws.setKeyIdHeaderValue(SIGNING_KEY.getKeyId());
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
return jws.getCompactSerialization();
}
public String getJwks() {
return new JsonWebKeySet(SIGNING_KEY).toJson();
}
}

To make the mock token signing public key available, the example tests use the WireMock tool to mock the authorization server and return a JSON Web Key Set on the JWKS URI that the API uses:

java
123456789101112131415
@BeforeAll
public static void startMockAuthorizationServer() throws ServletException {
var options = new WireMockConfiguration().port(8443).httpDisabled(false);
mockAuthorizationServer = new WireMockServer(options);
mockAuthorizationServer.start();
String jwksUrl = mockAuthorizationServer.baseUrl() + JWKS_PATH;
Logger.getLogger(AbstractApiAuthorizationTest.class.getName()).info("Mocked JWKS URL on " + jwksUrl);
mockAuthorizationServer.stubFor(get(JWKS_PATH)
.willReturn(
ok(mockJwtIssuer.getJwks())
)
);
}

Integration tests can then be coded productively by asking for a JWT with particular properties. This includes the user, claims to issue, and other properties such as the scope or audience. The following example test verifies that API's authorization filters products to match the user's country:

java
123456789101112131415161718192021
public class ListProductsAuthorizationTest extends AbstractApiAuthorizationTest {
HttpResponse<String> sendAuthenticatedRequest(String subjectName, Map<String, String> claims, String url) {
String jwt = mockJwtIssuer.getJwt(subjectName, claims, AUDIENCE);
return sendRequest(url, jwt);
}
@ParameterizedTest
@ValueSource(strings = { "se", "us", "de"})
void returnProductListForCountry(String country) {
HttpResponse<String> response = sendAuthenticatedRequest(
"Alice",
Map.of("country", country,
"scope", SCOPE),
applicationUrl("/api/products"));
assertEquals(200, response.statusCode(), "Response Code");
Collection<Product> productList = new ProductServiceImpl().getProductsForCountry(country);
assertEquals(JsonUtil.getJsonArrayFromCollection(productList).toString(), response.body());
}
}

In this manner, all of the main authentication and authorization use cases can be tested, including invalid JWT access tokens and requests for invalid data. These checks become part of the API's test suite, and would run whenever the API code changes.

text
12345
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Results:
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0

Conclusion

Although end-to-end OAuth solutions often have many moving parts, developing zero trust APIs does not need to slow down API development. Instead, API developers can use simple mocking techniques in any API technology stack to test both authentication and authorization. These tests can then be verified frequently, as part of a secure development lifecycle.

Join our Newsletter

Get the latest on identity management, API Security and authentication straight to your inbox.

Start Free Trial

Try the Curity Identity Server for Free. Get up and running in 10 minutes.

Start Free Trial