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:
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.
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:
{"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:
mvn package -DskipTests
Next, run the following command to run the API locally on port 9090:
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:
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.
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/* routeFilter 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 routespath("/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.
public class ListProductsRequestHandler extends ProductRequestHandler {{@Overridepublic 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:
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:
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:
@BeforeAllpublic 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:
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.
[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