diff --git a/distribution/examples/openapi/jwt-auth/README.md b/distribution/examples/openapi/jwt-auth/README.md index 1f7481ca05..21083952d1 100644 --- a/distribution/examples/openapi/jwt-auth/README.md +++ b/distribution/examples/openapi/jwt-auth/README.md @@ -12,10 +12,13 @@ This example demonstrates how to secure an (Open)API using JWT authentication wi ## OpenAPI Specification with Scopes -In `proxies.xml`, reference the OpenAPI file: +In `apis.yaml`, reference the OpenAPI file: -```xml - +```yaml + specs: + - openapi: + location: secure-shop-api.yml + validateSecurity: true ``` This makes Membrane automatically enforce the security rules defined in the spec. @@ -52,45 +55,50 @@ paths: --- -## 2. Configure `proxies.xml` +## 2. Configure `apis.yaml` **Token Server**: (Instead of using Membrane API Gateway as the token server, you can also integrate with Keycloak or Microsoft Entra ID. To avoid extra setup for this demo, we use tokens issued by Membrane itself.) -```xml - - - - - - - - - - +```yaml +# Token Server +api: + name: Token Server + port: 2000 + flow: + - request: + - template: + src: | + { + "sub": "user@example.com", + "aud": "shop", + "scp": "inventory" + } + - jwtSign: + jwk: + location: jwk.json + - return: {} ``` **Protected API**: -```xml - - - - - - - - - - - - - - +```yaml +# Protected API +api: + name: Protected API + port: 2001 + specs: + - openapi: + location: secure-shop-api.yml + validateSecurity: true + flow: + - jwtAuth: + expectedAud: shop + jwks: + jwks: + - jwk: + location: jwk.json + - openapiValidator: {} ``` --- @@ -115,7 +123,15 @@ membrane.cmd --- -## 4. Request a Token +## 4. Try to access the Protected API + +```cmd +curl http://localhost:2001/shop/v2/products +``` + +--- + +## 5. Request a Token ```cmd curl http://localhost:2000 @@ -131,7 +147,7 @@ The token includes `scp: "inventory"`, which satisfies the `GET /products` scope --- -## 5. Access the Protected API +## 6. Access the Protected API ```bash curl -H "Authorization: Bearer " http://localhost:2001/shop/v2/products diff --git a/distribution/examples/openapi/jwt-auth/apis.yaml b/distribution/examples/openapi/jwt-auth/apis.yaml index 8d2d3c48cf..070c08d66c 100644 --- a/distribution/examples/openapi/jwt-auth/apis.yaml +++ b/distribution/examples/openapi/jwt-auth/apis.yaml @@ -14,6 +14,7 @@ api: - jwtSign: jwk: location: jwk.json + - return: {} --- diff --git a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/openapi/OpenApiJwtAuthExampleTest.java b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/openapi/OpenApiJwtAuthExampleTest.java new file mode 100644 index 0000000000..a275f83e42 --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/openapi/OpenApiJwtAuthExampleTest.java @@ -0,0 +1,120 @@ +package com.predic8.membrane.examples.withoutinternet.openapi; + +import com.predic8.membrane.examples.util.DistributionExtractingTestcase; +import com.predic8.membrane.examples.util.Process2; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; + +import static com.predic8.membrane.core.util.OSUtil.isWindows; +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.*; + +public class OpenApiJwtAuthExampleTest extends DistributionExtractingTestcase { + + private Process2 process; + + @Override + protected String getExampleDirName() { + return "openapi/jwt-auth"; + } + + @BeforeEach + void setup() throws Exception { + runGenerateJwk(getExampleDir(getExampleDirName())); + process = startServiceProxyScript(); + } + + @AfterEach + void stopMembrane() { + if (process != null) + process.killScript(); + } + + private static String fetchJwt() { + // @formatter:off + return given() + .when() + .get("http://localhost:2000/") + .then() + .statusCode(200) + .extract() + .asString() + .trim(); + // @formatter:on + } + + @Test + void shouldListProducts_whenValidJwtProvided() { + String jwt = fetchJwt(); + // @formatter:off + given() + .header("Authorization", "Bearer " + jwt) + .when() + .get("http://localhost:2001/shop/v2/products") + .then() + .statusCode(200) + .contentType(JSON) + .body("meta.count", greaterThan(0)) + .body("products", is(not(empty()))) + .body("products[0].id", notNullValue()); + // @formatter:on + } + + @Test + void shouldReturnSecurityProblem_whenMissingJwt() { + // @formatter:off + given() + .accept(JSON) + .when() + .get("http://localhost:2001/shop/v2/products") + .then() + .statusCode(400) + .body("type", equalTo("https://membrane-api.io/problems/security")) + .body("detail", containsString("Could not retrieve JWT")); + // @formatter:on + } + + private static void runGenerateJwk(File dir) throws Exception { + File jwk = new File(dir, "jwk.json"); + if (jwk.isFile() && jwk.length() > 0) return; + + Process p = createJwkProcess(dir); + int exit = p.waitFor(); + + if (exit != 0) { + throw new IllegalStateException(MessageFormat.format("generate-jwk failed (exit {0}):\n{1}", exit, new String(p.getInputStream().readAllBytes(), UTF_8))); + } + if (!jwk.isFile() || jwk.length() == 0) { + throw new IllegalStateException("generate-jwk did not create jwk.json in %s".formatted(dir.getAbsolutePath())); + } + } + + private static @NotNull Process createJwkProcess(File dir) throws IOException { + File sh = new File(dir, "membrane.sh"); + File bat = new File(dir, "membrane.bat"); + + ProcessBuilder pb; + if (isWindows()) { + String script = bat.exists() ? "membrane.bat" : "membrane"; + pb = new ProcessBuilder("cmd", "/c", script, "generate-jwk", "-o", "jwk.json"); + } else { + String scriptPath = sh.exists() ? sh.getAbsolutePath() : new File(dir, "membrane").getAbsolutePath(); + pb = new ProcessBuilder("bash", scriptPath, "generate-jwk", "-o", "./jwk.json"); + } + + pb.directory(dir); + pb.redirectErrorStream(true); + + return pb.start(); + } + +}