diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/AbstractSecurityJwtTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/AbstractSecurityJwtTutorialTest.java new file mode 100644 index 0000000000..132f60a05e --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/AbstractSecurityJwtTutorialTest.java @@ -0,0 +1,26 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +package com.predic8.membrane.tutorials.security.jwt; + +import com.predic8.membrane.tutorials.AbstractMembraneTutorialTest; + +public abstract class AbstractSecurityJwtTutorialTest extends AbstractMembraneTutorialTest { + + @Override + protected String getTutorialDir() { + return "security"; + } + +} diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/IssuingAndValidatingJwtsTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/IssuingAndValidatingJwtsTutorialTest.java new file mode 100644 index 0000000000..777f15b6da --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/IssuingAndValidatingJwtsTutorialTest.java @@ -0,0 +1,75 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +package com.predic8.membrane.tutorials.security.jwt; + +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; + +public class IssuingAndValidatingJwtsTutorialTest extends AbstractSecurityJwtTutorialTest { + + @Override + protected String getTutorialYaml() { + return "50-Issuing-and-Validating-JWTs.yaml"; + } + + @Test + void issuesTokenAndProtectsResource() { + // 1) Wrong credentials are rejected by HTTP Basic authentication. + // @formatter:off + given() + .auth().preemptive().basic("alice", "wrong") + .when() + .post("http://localhost:2000/token") + .then() + .statusCode(401); + // @formatter:on + + // 2) The protected resource requires a token. + // @formatter:off + given() + .when() + .get("http://localhost:2000/resource") + .then() + .statusCode(400); + // @formatter:on + + // 3) The authenticated user gets a token whose "sub" is their own username. + // @formatter:off + String accessToken = + given() + .auth().preemptive().basic("alice", "alice-secret") + .when() + .post("http://localhost:2000/token") + .then() + .statusCode(200) + .body("token_type", equalTo("bearer")) + .body("expires_in", equalTo(300)) + .body("access_token", notNullValue()) + .extract().path("access_token"); + + given() + .header("Authorization", "Bearer " + accessToken) + .when() + .get("http://localhost:2000/resource") + .then() + .statusCode(200) + .body("client", equalTo("alice")) + .body("scopes", equalTo("read write")); + // @formatter:on + } +} diff --git a/distribution/tutorials/README.md b/distribution/tutorials/README.md index be549e94ba..dd1743e776 100644 --- a/distribution/tutorials/README.md +++ b/distribution/tutorials/README.md @@ -35,6 +35,11 @@ Run and observe Membrane in production. Expose Membrane as an MCP server for AI clients, inspect recent API traffic, and protect the MCP endpoint with an API key. +## [Security](security) + +Issue signed JSON Web Tokens and protect an API by validating them. Covers the OAuth2 client-credentials flow, Bearer tokens, and signature/expiry/audience checks. + + ## [SOAP Web Services (Legacy)](soap) If you need to integrate legacy SOAP Web Services, this tutorial provides examples and practical guidance. diff --git a/distribution/tutorials/security/40-Requesting-a-JWT.md b/distribution/tutorials/security/40-Requesting-a-JWT.md new file mode 100644 index 0000000000..85277cc599 --- /dev/null +++ b/distribution/tutorials/security/40-Requesting-a-JWT.md @@ -0,0 +1,44 @@ +# Requesting a JWT + +No setup required, just `curl`. Uses the public Membrane demo at `https://api.predic8.de`. + +Based on: + +## 1. Request a token + +```sh +curl -X POST https://api.predic8.de/demo/oauth2/token \ + -u "my-client:my-secret" \ + -d "grant_type=client_credentials" +``` + +```json +{"access_token":"eyJ0eXAiOiJKV1Qi...","token_type":"bearer","expires_in":300} +``` + +## 2. Inspect the token + +Paste the `access_token` into . A JWT has three parts `header.payload.signature`: + +- `sub` — subject (the client id) +- `aud` — audience (the API this token is for) +- `scopes` — permissions granted +- `exp` — expiry (300s) + +## 3. Call the protected resource + +```sh +curl https://api.predic8.de/demo/resource \ + -H "Authorization: Bearer eyJ0eXAiOiJKV1Qi..." +``` + +```json +{ "success": true, "user": "my-client", "scopes": "read write" } +``` + +Try it without the header — the request is rejected. + +## Next + +Continue with [50-Issuing-and-Validating-JWTs.yaml](50-Issuing-and-Validating-JWTs.yaml) +where Membrane issues and validates the tokens itself. diff --git a/distribution/tutorials/security/50-Issuing-and-Validating-JWTs.yaml b/distribution/tutorials/security/50-Issuing-and-Validating-JWTs.yaml new file mode 100644 index 0000000000..a89cfc5c54 --- /dev/null +++ b/distribution/tutorials/security/50-Issuing-and-Validating-JWTs.yaml @@ -0,0 +1,82 @@ +# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json +# +# Tutorial: Issuing and Validating JWTs with Membrane +# +# Membrane acts as both token server and protected resource - no external server needed. +# +# 1.) Start Membrane: +# ./membrane.sh -c 20-Issuing-and-Validating-JWTs.yaml +# +# 2.) Request a token: +# curl -X POST localhost:2000/token -u "alice:alice-secret" +# +# {"access_token":"eyJ0eXAiOiJKV1Qi...","token_type":"bearer","expires_in":300} +# +# 3.) Paste the token into https://jwt.io - notice sub, aud, scopes, exp. +# +# 4.) Call the resource without a token (rejected): +# curl -i localhost:2000/resource +# +# 5.) Call it with the token: +# curl localhost:2000/resource -H "Authorization: Bearer eyJ0eXAiOiJKV1Qi..." +# +# {"client":"alice","scopes":"read write"} +# +# NOTE: jwk.json contains a demo key (private+public) - generate your own for production. +# jwk-public.json holds only the public parameters and is used by jwtAuth for validation. + +api: + port: 2000 + name: Token Server + path: + uri: /token + flow: + - basicAuthentication: + users: + - username: alice + password: alice-secret + - request: + # user() returns the authenticated username; jwtSign adds iat/exp/nbf. + - template: + contentType: application/json + src: | + { + "sub": ${user()}, + "aud": "demo-resource", + "scopes": "read write" + } + - jwtSign: + property: token + jwk: + location: jwk.json + - template: + contentType: application/json + src: | + { + "access_token": ${property.token}, + "token_type": "bearer", + "expires_in": 300 + } + - return: + status: 200 +--- +api: + port: 2000 + name: Protected Resource + path: + uri: /resource + flow: + - jwtAuth: + expectedAud: demo-resource + jwks: + jwks: + - location: jwk-public.json + - template: + contentType: application/json + src: | + { + "client": ${property.jwt.get("sub")}, + "scopes": ${property.jwt.get("scopes")} + } + - return: + status: 200 diff --git a/distribution/tutorials/security/README.md b/distribution/tutorials/security/README.md new file mode 100644 index 0000000000..aed07bea32 --- /dev/null +++ b/distribution/tutorials/security/README.md @@ -0,0 +1,24 @@ +# JWT Authentication Tutorial + +Learn how to protect an API with JSON Web Tokens (JWT). A client exchanges its +credentials for a short-lived, signed token and then uses that token as a Bearer +token on each request, while the gateway validates the signature, expiry and +audience on every call. + +Each step is explained directly in the configuration file, which is also the +Membrane config you run. If possible, use an editor with YAML support such as +Visual Studio Code or IntelliJ IDEA. + +The tutorials build on each other, from simple to advanced: + +1. [40-Requesting-a-JWT.md](40-Requesting-a-JWT.md) — a `curl`-only walkthrough of the + hosted [Membrane demo](https://www.membrane-api.io/jwt/jwt-api-authentication-authorization-tutorial.html): + request a token via the OAuth2 Client Credentials flow and use it to call a + protected API. Nothing to run locally. +2. [50-Issuing-and-Validating-JWTs.yaml](50-Issuing-and-Validating-JWTs.yaml) — let Membrane itself + issue and validate the tokens, fully offline. + +## Next Steps + +Start with [40-Requesting-a-JWT.md](40-Requesting-a-JWT.md), then run +[50-Issuing-and-Validating-JWTs.yaml](50-Issuing-and-Validating-JWTs.yaml). diff --git a/distribution/tutorials/security/jwk-public.json b/distribution/tutorials/security/jwk-public.json new file mode 100644 index 0000000000..f903b724a3 --- /dev/null +++ b/distribution/tutorials/security/jwk-public.json @@ -0,0 +1,8 @@ +{ + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "membrane", + "alg": "RS256", + "n": "jy8oj0NscvKCaawqk_f53p-iroACUxIz1ysSfwabydA22QDIbEtJ_Z7UNiW4dbdQUpzcsdUTG--Es7ECEAvxn3Q3jxMX7hU0n75s_KHcfm1yao508F913YuMmP2THtMmBiT0cFbxVHkJD_QvcwWPqTcjqcc4n7MYVeVZaLq0KJ-pz2Avb7a7fx0Ouk2pAgO4reFiR43T6wo_dyxcN92TjhbK3z8Qmox8kME-ZukNmDIAlm_UHzKupZM8cGotP3f42xeigUZXiVwDdAxQeZgV8mWNHVIYKdDYccQxHETu94jOPUQwXL-vVp8PHTeLqhY1oGcT2EJ6SEpH6e5NIvBxhQ" +} diff --git a/distribution/tutorials/security/jwk.json b/distribution/tutorials/security/jwk.json new file mode 100644 index 0000000000..80429e255f --- /dev/null +++ b/distribution/tutorials/security/jwk.json @@ -0,0 +1,14 @@ +{ + "p": "wP1ITsKxAWOO03eywdOj73T5Po1OFXZlFgzf1CJaf8D3piuxS0C5JSHTKTO354_r9cg2WyQ3nEqJ6YScV3-NfW8xXbiyMr5Xokzn7YpuB9dtby0veEn4w7JHChH5lV2fwrjH2iL6IIOLrND9D_Dxoc3mmLMaie0mTW9-UHGOunk", + "kty": "RSA", + "q": "ve7893s-DMjWEFZSsUaj0wQh9LicfSDBlUzISR5ojYTDphQ2RRm1b3TLHHM2okP79BXrX-Jq6NDVVVsi0DJmkvNkivNMxSuqvf8Qo1G6YI_NgEzGCM9Nq1MZkwbrBQDHfiHQIxm4Td_OE3LUi7mR7Zye4aJIu0mx9W33jIaAbG0", + "d": "Hqi5ZZvJT_-vfxMXduGlRk8mVXkhhkoigZM-faabmyYTaHnrcIzahg0JYaLIEaSz9UyTURzP36502skvKOJ11W_cKa2r9RXjU8VBrwK1pPiohDqGvaWjJlIoQ-YgJ3yM6snk8V0chbr4_sqJknaBYXlmEIeRD1kY_-OBNpSr2PqtMj3j6v3XlhvLTRbVPA6aVIiPLimX5m-Xmo5jsSOj10CIJ3RkCNaegogUfX8V0eUPYl2OsBzGWEpzpBOKt9ej4pHcidejmQpnjkBUSlDMdz0jzgQYcLlCg6v62JI66yMtFBDNTbKo0q5niNN0BWSQZH8vEL_crCSfAiGT2wqsoQ", + "e": "AQAB", + "use": "sig", + "kid": "membrane", + "qi": "tbgI_1rCyz5Xb4jO004io5k6x42O4WghFdbL3-rvYcLdVVlUf6tdncvN2pgykUYqBHneZoedzILSZsmdwZypGcZXgYTPFxx3j8bqRwOPNAYK-BBzM-WqQSU5TtgkUAa83YsJy10cLQsHHaasOFX0nPT77jZQo_HeezzIAH9tgXY", + "dp": "hTwXeHCG_Rt7lljT62aujfmmvV2Wo9CaFzAKMw0Ih5x0HJ-bhgWIDK-edZqEA3TkBUoU5LVLQzZeof3wZaPkzc0_OqHxPIEWRTFtCRyBvB4pKhD67cO733cr_jLMqSb6zdb9-oYdQucuPcAGhcPlPbzFz3QPBVvZDqrDfMv5Kpk", + "alg": "RS256", + "dq": "qqQMokwXc2T87bCgmqTcirkryLIT5leHlJtnVkn7pSminZOLLonqeDh2Qxk__IkX1DPdREgnxQPaptU6cdLWVTBXJH9yebLBs_F1AUZsLFUGTD6trTySi1odn_qXK-eHU8sNNHvnGg_5FYAVdXNDqDcOh6lFrv6G4_noblhpCQ", + "n": "jy8oj0NscvKCaawqk_f53p-iroACUxIz1ysSfwabydA22QDIbEtJ_Z7UNiW4dbdQUpzcsdUTG--Es7ECEAvxn3Q3jxMX7hU0n75s_KHcfm1yao508F913YuMmP2THtMmBiT0cFbxVHkJD_QvcwWPqTcjqcc4n7MYVeVZaLq0KJ-pz2Avb7a7fx0Ouk2pAgO4reFiR43T6wo_dyxcN92TjhbK3z8Qmox8kME-ZukNmDIAlm_UHzKupZM8cGotP3f42xeigUZXiVwDdAxQeZgV8mWNHVIYKdDYccQxHETu94jOPUQwXL-vVp8PHTeLqhY1oGcT2EJ6SEpH6e5NIvBxhQ" +} \ No newline at end of file