userAttributes = new HashMap<>();
private int level = 0;
- private long lastUse;
+ // Initialized to the creation time: cleanup() treats lastUse as the staleness
+ // timestamp, and a 0 here would make every never-touched session (e.g. one only
+ // backing a refresh token) die on the first cleanup sweep.
+ private long lastUse = System.currentTimeMillis();
private String userName;
public synchronized boolean isAuthorized() {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/HeaderJwtRetriever.java b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/HeaderJwtRetriever.java
index 87968ee8d4..2befbe5534 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/HeaderJwtRetriever.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/HeaderJwtRetriever.java
@@ -17,10 +17,14 @@
import com.predic8.membrane.annot.MCElement;
import com.predic8.membrane.core.exchange.Exchange;
import com.predic8.membrane.annot.Required;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
@MCElement(name = "headerJwtRetriever")
public class HeaderJwtRetriever implements JwtRetriever{
+ private static final Logger log = LoggerFactory.getLogger(HeaderJwtRetriever.class);
+
String header;
String removeFromValue;
@@ -36,8 +40,10 @@ public HeaderJwtRetriever(String header, String removeFromValue) {
public String get(Exchange exc) {
String[] replace = removeFromValue.split(" ");
String header = exc.getRequest().getHeader().getFirstValue(this.header);
- if(header == null)
+ if(header == null) {
+ log.info("Header {} not found in request", this.header);
return null;
+ }
for (String replaceMe : replace) {
header = header.replaceAll("(?i)" + replaceMe.trim(),"");
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
index 84e4029f61..790874e40b 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java
@@ -48,7 +48,13 @@
/**
* @description
- * JSON Web Key Set, configured either by an explicit list of JWK or by a list of JWK URIs that will be refreshed periodically.
+ * JSON Web Key Set, configured either by an explicit list of JWK or by a list of JWK URIs.
+ * When JWK URIs are used, the keys are fetched at startup. If they cannot be retrieved at that point
+ * (e.g. the issuer is not running yet), the gateway starts anyway and fetching is retried on the first
+ * request that needs the keys. Until the keys have been retrieved, requests validated against this key
+ * set are rejected.
+ * If an authorizationService with a jwksRefreshInterval is configured, the key set is additionally
+ * refreshed periodically.
*/
@MCElement(name="jwks")
public class Jwks {
@@ -66,6 +72,7 @@ public class Jwks {
List jwksUris = emptyList();
AuthorizationService authorizationService;
private Router router;
+
private final Runnable refreshJwksTask = () -> {
try {
List loaded = loadJwks(true);
@@ -89,13 +96,39 @@ public void init(Router router) {
}
if (!jwks.isEmpty())
throw new ConfigurationException("JWKs cannot be set both via JwksUris and Jwks elements.");
- setJwks(loadJwks(false));
+
+ try {
+ setJwks(loadJwks(false));
+ } catch (Exception e) {
+ log.warn("Could not load JWKs from {} ({}). Maybe the server is not yet available. I'll try it later. Ignore when token server and resource are served from the same configuration.", jwksUris, e.getMessage());
+ log.debug("JWKS load failure", e);
+ }
+
if (authorizationService != null && authorizationService.getJwksRefreshInterval() > 0) {
router.getTimerManager().schedulePeriodicTask(createTimerTask(refreshJwksTask), authorizationService.getJwksRefreshInterval() * 1_000L, "JWKS Refresh");
}
}
+ /**
+ * The JWKS URIs may be unreachable during init() (e.g. Membrane starts before the issuer);
+ * init() only logs that failure, so the load is retried here on first use. If the keys still
+ * cannot be retrieved, this throws and the current request fails; the next request retries.
+ * Double-checked locking (jwks is volatile) so the common already-loaded case avoids the lock,
+ * while concurrent first requests still do not fetch the JWKS multiple times.
+ */
+ private void reloadJwksIfNeeded() {
+ if (jwks != null && !jwks.isEmpty())
+ return;
+ synchronized (this) {
+ // re-check: another thread may have loaded the JWKS while we waited for the lock
+ if (jwks != null && !jwks.isEmpty())
+ return;
+ setJwks(loadJwks(false));
+ }
+ }
+
public List getJwks() {
+ reloadJwksIfNeeded();
return jwks;
}
@@ -121,6 +154,7 @@ public void setJwksUris(String jwksUris) {
}
public Optional getKeyByKid(String kid) {
+ reloadJwksIfNeeded();
return Optional.ofNullable(keysByKid.get(kid));
}
@@ -151,6 +185,7 @@ private Map buildKeyMap(List jwks) {
}
private List loadJwks(boolean suppressExceptions) {
+ log.debug("Loading JWKs from {}.", jwksUris);
return jwksUris.stream()
.map(uri -> parseJwksUriIntoList(uri, suppressExceptions))
.flatMap(l -> l.jwks().stream().map(jwkRaw -> convertToJwk(jwkRaw, mapper, l.uri(), suppressExceptions)))
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java
index 05c0eeb7ce..a9b84ada41 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java
@@ -43,6 +43,7 @@
*
* jwtAuth:
* expectedAud: my-audience
+ * expectedIss: https://auth.example.com
* expectedTid: 67c859d3-0cd4-4a99-86db-088bed1a9601
* jwks: {}
*
@@ -72,6 +73,8 @@ public static String ERROR_JWT_VALUE_NOT_PRESENT(String key) {
Jwks jwks;
String expectedAud;
String expectedTid;
+ String expectedIss;
+ String scopesClaim = "scp";
public JwtAuthInterceptor() {
@@ -130,7 +133,7 @@ public Outcome handleRequest(Exchange exc) {
} catch (Exception e) {
log.info("Could not retrieve JWT: {}", e.getMessage());
security(router.getConfiguration().isProduction(), "jwt-auth")
- .detail(ERROR_JWT_NOT_FOUND)
+ .detail(e.getMessage())
.addSubSee(ERROR_JWT_NOT_FOUND_ID)
.stacktrace(true)
.status(401)
@@ -150,13 +153,16 @@ public Outcome handleJwt(Exchange exc, String jwt) throws JWTException, JsonProc
// we could make it possible that every key is checked instead of having the "kid" field mandatory
// this would then need up to n checks per incoming JWT - could be a performance problem
- RsaJsonWebKey key = jwks.getKeyByKid(kid).orElseThrow(() -> new JWTException(ERROR_UNKNOWN_KEY, ERROR_UNKNOWN_KEY_ID));
+ var key = jwks.getKeyByKid(kid).orElseThrow(() -> {
+ log.info("JWT signed by unknown key: {}",kid);
+ return new JWTException(ERROR_UNKNOWN_KEY, ERROR_UNKNOWN_KEY_ID);
+ });
Map jwtClaims = createValidator(key).processToClaims(jwt).getClaimsMap();
exc.getProperties().put("jwt",jwtClaims);
- new JWTSecurityScheme(jwtClaims).add(exc);
+ new JWTSecurityScheme(jwtClaims, scopesClaim).add(exc);
return CONTINUE;
}
@@ -180,6 +186,10 @@ private JwtConsumer createValidator(RsaJsonWebKey key) {
jwtConsumerBuilder
.registerValidator(new TidValidator(expectedTid));
+ if (expectedIss != null && !expectedIss.isEmpty())
+ jwtConsumerBuilder
+ .setExpectedIssuer(expectedIss);
+
return jwtConsumerBuilder.build();
}
@@ -234,6 +244,39 @@ public void setExpectedTid(String expectedTid) {
this.expectedTid = expectedTid;
}
+ public String getExpectedIss() {
+ return expectedIss;
+ }
+
+ /**
+ * @description
+ * Expected issuer ('iss') value of the token. If set, tokens without an 'iss' claim or with a
+ * different issuer are rejected.
+ * @default not set
+ * @example https://auth.example.com
+ */
+ @MCAttribute
+ public void setExpectedIss(String expectedIss) {
+ this.expectedIss = expectedIss;
+ }
+
+ public String getScopesClaim() {
+ return scopesClaim;
+ }
+
+ /**
+ * @description
+ * Name of the claim that carries the token's scopes, e.g. "scp" (Microsoft Entra ID)
+ * or "scope" (RFC 9068). The claim may hold a space separated string or a list of strings.
+ * The scopes are used by the OpenAPI security validation.
+ * @default scp
+ * @example scope
+ */
+ @MCAttribute
+ public void setScopesClaim(String scopesClaim) {
+ this.scopesClaim = scopesClaim;
+ }
+
@Override
public String getShortDescription() {
return "Checks for a valid JWT.";
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/Client.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/Client.java
index 20088b09e3..dc2746f1e7 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/Client.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/Client.java
@@ -17,12 +17,16 @@
import com.predic8.membrane.annot.MCElement;
import com.predic8.membrane.annot.Required;
+import java.util.List;
+
@MCElement(name="client", component =false, id="staticClientList-client")
public class Client {
private String clientId;
private String clientSecret;
private String callbackUrl;
private String grantTypes = "authorization_code,password,client_credentials,refresh_token,implicit";
+ private String resources;
+ private String scopes;
public Client(){
}
@@ -64,7 +68,6 @@ public String getCallbackUrl() {
return callbackUrl;
}
- @Required
@MCAttribute
public void setCallbackUrl(String callbackUrl) {
this.callbackUrl = callbackUrl;
@@ -83,4 +86,50 @@ public void setGrantTypes(String grantTypes) {
this.grantTypes = grantTypes;
}
+ public String getResources() {
+ return resources;
+ }
+
+ /**
+ * @description Space separated list of resource URIs (audiences) this client may request tokens for,
+ * see RFC 8707. In the client_credentials grant, the "resource" request parameter is
+ * checked against this list, and the granted resources become the "aud" claim of the
+ * issued token (requires a JWT token generator such as bearerJwtToken). If the request
+ * contains no "resource" parameter, all resources listed here are granted.
+ */
+ @MCAttribute
+ public void setResources(String resources) {
+ this.resources = resources;
+ }
+
+ public List getResourceList() {
+ return splitList(resources);
+ }
+
+ public String getScopes() {
+ return scopes;
+ }
+
+ /**
+ * @description Space separated list of scopes this client may request in the client_credentials
+ * grant. The "scope" request parameter is checked against this list, and the granted
+ * scopes become the "scope" claim of the issued token (requires a JWT token generator
+ * such as bearerJwtToken). If the request contains no "scope" parameter, all scopes
+ * listed here are granted. Without this attribute, the "scope" parameter is passed
+ * through unchecked and no "scope" claim is issued.
+ */
+ @MCAttribute
+ public void setScopes(String scopes) {
+ this.scopes = scopes;
+ }
+
+ public List getScopeList() {
+ return splitList(scopes);
+ }
+
+ private static List splitList(String value) {
+ if (value == null || value.isBlank())
+ return List.of();
+ return List.of(value.trim().split(" +"));
+ }
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/OAuth2AuthorizationServerInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/OAuth2AuthorizationServerInterceptor.java
index 7b411f7bbc..f4d9297275 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/OAuth2AuthorizationServerInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/OAuth2AuthorizationServerInterceptor.java
@@ -97,6 +97,9 @@ public void init() {
throw new ConfigurationException("Could not create token generators.",e);
}
+ tokenGenerator.setIssuer(issuer);
+ refreshTokenGenerator.setIssuer(issuer);
+
addSupportedAuthorizationGrants();
try {
@@ -111,8 +114,13 @@ public void init() {
} catch (IOException e) {
throw new ConfigurationException("Could not create Consent Page file.",e);
}
- if (userDataProvider == null)
- throw new ConfigurationException("No userDataProvider configured. - Cannot work without one.");
+ if (userDataProvider == null) {
+ log.warn("=====================================================================================================");
+ log.warn("IMPORTANT: No userDataProvider configured - Only flows without users (e.g. client credentials) are");
+ log.warn("available. Authorization code, implicit and password flows will reject all requests.");
+ log.warn("=====================================================================================================");
+ loginViewDisabled = true;
+ }
if (getClientList() == null)
throw new ConfigurationException("No clientList configured. - Cannot work without one.");
if (getClaimList() == null)
@@ -131,7 +139,8 @@ public void init() {
}
if(getPath() == null)
throw new ConfigurationException("No path configured. - Cannot work without one");
- userDataProvider.init(router);
+ if (userDataProvider != null)
+ userDataProvider.init(router);
getClientList().init(router);
getClaimList().init(router);
try {
@@ -181,7 +190,11 @@ public UserDataProvider getUserDataProvider() {
return userDataProvider;
}
- @Required
+ /**
+ * @description Provides the user data (e.g. from a static list or a database). Required for the
+ * authorization code, implicit and password flows. Can be omitted if only user-less
+ * flows like client_credentials are used.
+ */
@MCChildElement(order = 1)
public void setUserDataProvider(UserDataProvider userDataProvider) {
this.userDataProvider = userDataProvider;
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/ParamNames.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/ParamNames.java
index 53117ff873..b268fb9549 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/ParamNames.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/ParamNames.java
@@ -30,6 +30,7 @@ public class ParamNames {
public static final String PASSWORD = "password";
public static final String REFRESH_TOKEN = "refresh_token";
public static final String ACCESS_TOKEN = "access_token";
+ public static final String RESOURCE = "resource";
/**
* Holds username/e-mail information to streamline the login process
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/SessionFinder.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/SessionFinder.java
index 0f3232e56a..945817049a 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/SessionFinder.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/SessionFinder.java
@@ -102,6 +102,12 @@ public void removeSessionForToken(String token) {
}
}
+ public void removeSessionForRefreshToken(String refreshToken) {
+ synchronized (refreshTokensToSession) {
+ refreshTokensToSession.remove(refreshToken);
+ }
+ }
+
public void cleanupSessions(Set sessionsToRemove) {
cleanupMap(sessionsToRemove, authCodesToSession);
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/ParameterizedRequest.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/ParameterizedRequest.java
index 54a3221cf0..bd511cf75c 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/ParameterizedRequest.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/ParameterizedRequest.java
@@ -13,7 +13,6 @@
package com.predic8.membrane.core.interceptor.oauth2.request;
-import com.google.common.collect.ImmutableMap;
import com.predic8.membrane.core.exchange.Exchange;
import com.predic8.membrane.core.http.Header;
import com.predic8.membrane.core.http.Response;
@@ -23,7 +22,10 @@
import com.predic8.membrane.core.interceptor.oauth2.ParamNames;
import com.predic8.membrane.core.util.URLParamUtil;
-import java.util.*;
+import java.util.AbstractMap;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -104,6 +106,8 @@ protected void addParams(SessionManager.Session session, Map para
}
protected Map verifyUserThroughParams(){
+ if (authServer.getUserDataProvider() == null)
+ return null;
try {
return authServer.getUserDataProvider().verify(params);
}catch (Exception ignored){
@@ -147,25 +151,34 @@ protected String createTokenForVerifiedUserAndClient(Map userPar
}
protected Map claimsMap(Map userParams) {
+ Map claims = new HashMap<>();
if (userParams.containsKey("aud"))
- return ImmutableMap.of("aud", userParams.get("aud").split(" "));
- return ImmutableMap.of();
+ claims.put("aud", userParams.get("aud").split(" "));
+ if (userParams.containsKey("scopes"))
+ claims.put("scope", userParams.get("scopes"));
+ return claims;
}
protected Map claimsMapForRefresh(Map userParams) {
+ Map claims = new HashMap<>();
if (userParams.containsKey("aud"))
- return ImmutableMap.of("i-aud", userParams.get("aud").split(" "));
- return ImmutableMap.of();
+ claims.put("i-aud", userParams.get("aud").split(" "));
+ if (userParams.containsKey("scopes"))
+ claims.put("i-scope", userParams.get("scopes"));
+ return claims;
}
protected Map claimsMapFromRefresh(Map refreshClaims) {
+ Map claims = new HashMap<>();
if (refreshClaims.containsKey("i-aud"))
- return ImmutableMap.of("aud", refreshClaims.get("i-aud"));
- return ImmutableMap.of();
+ claims.put("aud", refreshClaims.get("i-aud"));
+ if (refreshClaims.containsKey("i-scope"))
+ claims.put("scope", refreshClaims.get("i-scope"));
+ return claims;
}
- protected String createTokenForVerifiedClient(){
- return authServer.getTokenGenerator().getToken(getClientId(), getClientId(), getClientSecret(), null);
+ protected String createTokenForVerifiedClient(Map additionalClaims){
+ return authServer.getTokenGenerator().getToken(getClientId(), getClientId(), getClientSecret(), additionalClaims);
}
public String getPrompt() {
@@ -214,4 +227,6 @@ public void setScopeInvalid(String invalidScopes){
public String getRefreshToken(){return params.get(ParamNames.REFRESH_TOKEN);}
+ public String getResource(){return params.get(ParamNames.RESOURCE);}
+
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/UserinfoRequest.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/UserinfoRequest.java
index aa281c2e27..0d8f504fa8 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/UserinfoRequest.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/UserinfoRequest.java
@@ -18,6 +18,8 @@
import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.interceptor.oauth2.*;
import com.predic8.membrane.core.interceptor.oauth2.parameter.ClaimsParameter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.HashMap;
@@ -25,6 +27,8 @@
import java.util.Map;
public class UserinfoRequest extends ParameterizedRequest {
+ private static final Logger log = LoggerFactory.getLogger(UserinfoRequest.class);
+
private TokenAuthorizationHeader authHeader;
private HashMap sessionProperties;
@@ -44,7 +48,13 @@ protected Response checkForMissingParameters() throws Exception {
@Override
protected Response processWithParameters() throws Exception {
- if(!authHeader.isValid() || !authServer.getSessionFinder().hasSessionForToken(authHeader.getToken())) {
+ // isValid() must be checked first: getToken() throws on a malformed Authorization header
+ if(!authHeader.isValid()) {
+ log.info("Access token at userinfo endpoint not accepted: token is invalid.");
+ return buildWwwAuthenticateErrorResponse( Response.unauthorized(), "invalid_token");
+ }
+ if(!authServer.getSessionFinder().hasSessionForToken(authHeader.getToken())) {
+ log.info("Access token at userinfo endpoint not accepted: token is valid but has no associated session.");
return buildWwwAuthenticateErrorResponse( Response.unauthorized(), "invalid_token");
}
sessionProperties = new HashMap<>(authServer.getSessionFinder().getSessionForToken(authHeader.getToken()).getUserAttributes());
@@ -52,7 +62,6 @@ protected Response processWithParameters() throws Exception {
String token = authHeader.getToken();
String username = authServer.getTokenGenerator().getUsername(token);
-
return new NoResponse();
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/CredentialsFlow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/CredentialsFlow.java
index b134a1ff0e..adf1a242d0 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/CredentialsFlow.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/CredentialsFlow.java
@@ -17,22 +17,28 @@
import com.predic8.membrane.core.http.MimeType;
import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.interceptor.authentication.session.SessionManager;
-import com.predic8.membrane.core.interceptor.oauth2.ClaimRenamer;
-import com.predic8.membrane.core.interceptor.oauth2.Client;
-import com.predic8.membrane.core.interceptor.oauth2.OAuth2AuthorizationServerInterceptor;
-import com.predic8.membrane.core.interceptor.oauth2.OAuth2Util;
-import com.predic8.membrane.core.interceptor.oauth2.ParamNames;
+import com.predic8.membrane.core.interceptor.oauth2.*;
import com.predic8.membrane.core.interceptor.oauth2.parameter.ClaimsParameter;
import com.predic8.membrane.core.interceptor.oauth2.request.NoResponse;
import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.JwtGenerator;
+import org.jose4j.lang.JoseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
-import org.jose4j.lang.JoseException;
+import static java.util.Arrays.stream;
public class CredentialsFlow extends TokenRequest {
+ private static final Logger log = LoggerFactory.getLogger(CredentialsFlow.class);
+
public CredentialsFlow(OAuth2AuthorizationServerInterceptor authServer, Exchange exc) throws Exception {
super(authServer, exc);
}
@@ -50,16 +56,6 @@ protected Response processWithParameters() throws Exception {
if(!verifyClientThroughParams())
return OAuth2Util.createParameterizedJsonErrorResponse("error","unauthorized_client");
- scope = getScope();
-
- token = createTokenForVerifiedClient();
- expiration = authServer.getTokenGenerator().getExpiration();
-
- SessionManager.Session session = createSessionForAuthorizedClientWithParams();
- synchronized(session) {
- session.getUserAttributes().put(ACCESS_TOKEN, token);
- }
-
Client client;
try {
synchronized (authServer.getClientList()) {
@@ -68,27 +64,115 @@ protected Response processWithParameters() throws Exception {
} catch (Exception e) {
return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_client");
}
-
+
String grantTypes = client.getGrantTypes();
if (!grantTypes.contains(getGrantType())) {
+ log.info("Invalid grant type: {}", getGrantType());
return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_grant_type");
}
-
+
+ if (!requestedResourcesAllowed(client)) {
+ log.info("Invalid target: {}", getResource());
+ return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_target");
+ }
+
+ if (!requestedScopesAllowed(client)) {
+ log.info("Invalid scope: {}",getScope());
+ return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_scope");
+ }
+
+ String[] audiences = getAudiences(client);
+ List grantedScopes = getGrantedScopes(client);
+ scope = grantedScopes.isEmpty() ? getScope() : String.join(" ", grantedScopes);
+
+ token = createTokenForVerifiedClient(tokenClaims("aud", "scope", audiences, grantedScopes));
+ expiration = authServer.getTokenGenerator().getExpiration();
+
+ SessionManager.Session session = createSessionForAuthorizedClientWithParams();
+ synchronized(session) {
+ session.getUserAttributes().put(ACCESS_TOKEN, token);
+ }
+
authServer.getSessionFinder().addSessionForToken(token,session);
if (authServer.isIssueNonSpecRefreshTokens()) {
- refreshToken = authServer.getRefreshTokenGenerator().getToken(client.getClientId(), client.getClientId(), client.getClientSecret(), null);
+ refreshToken = authServer.getRefreshTokenGenerator().getToken(client.getClientId(), client.getClientId(), client.getClientSecret(), tokenClaims("i-aud", "i-scope", audiences, grantedScopes));
authServer.getSessionFinder().addSessionForRefreshToken(refreshToken, session);
}
if (authServer.isIssueNonSpecIdTokens() && OAuth2Util.isOpenIdScope(scope))
idToken = createSignedIdToken(session, client.getClientId(), client);
-
+
exc.setResponse(getEarlyResponse());
-
+
return new NoResponse();
}
-
+
+ private boolean requestedResourcesAllowed(Client client) {
+ String requested = getResource();
+ if (requested == null)
+ return true;
+ List allowed = client.getResourceList();
+ return stream(requested.trim().split(" +"))
+ .allMatch(resource -> isValidResourceUri(resource) && allowed.contains(resource));
+ }
+
+ private String[] getAudiences(Client client) {
+ String requested = getResource();
+ if (requested != null)
+ return requested.trim().split(" +");
+ return client.getResourceList().toArray(new String[0]);
+ }
+
+ /**
+ * A "scope" request parameter is only validated if the client has a scope allowlist;
+ * without one, it is passed through unchecked to preserve the previous behavior.
+ */
+ private boolean requestedScopesAllowed(Client client) {
+ String requested = getScope();
+ if (requested == null)
+ return true;
+ List allowed = client.getScopeList();
+ if (allowed.isEmpty())
+ return true;
+ return stream(requested.trim().split(" +")).allMatch(allowed::contains);
+ }
+
+ private List getGrantedScopes(Client client) {
+ List allowed = client.getScopeList();
+ if (allowed.isEmpty())
+ return List.of();
+ String requested = getScope();
+ if (requested == null)
+ return allowed;
+ return List.of(requested.trim().split(" +"));
+ }
+
+ private Map tokenClaims(String audClaimName, String scopeClaimName, String[] audiences, List grantedScopes) {
+ Map claims = new HashMap<>(audClaims(audClaimName, audiences));
+ if (!grantedScopes.isEmpty())
+ claims.put(scopeClaimName, String.join(" ", grantedScopes));
+ return claims;
+ }
+
+ private Map audClaims(String claimName, String[] audiences) {
+ if (audiences.length == 0)
+ return Map.of();
+ if (audiences.length == 1)
+ return Map.of(claimName, audiences[0]);
+ return Map.of(claimName, audiences);
+ }
+
+ private static boolean isValidResourceUri(String resource) {
+ try {
+ URI uri = new URI(resource);
+ return uri.isAbsolute() && uri.getFragment() == null;
+ } catch (URISyntaxException e) {
+ return false;
+ }
+ }
+
+
private JwtGenerator.Claim[] getValidIdTokenClaims(SessionManager.Session session){
ClaimsParameter cp = new ClaimsParameter(authServer.getClaimList().getSupportedClaims(),session.getUserAttributes().get(ParamNames.CLAIMS));
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/PasswordFlow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/PasswordFlow.java
index 43e525b52c..2a46eb8524 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/PasswordFlow.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/PasswordFlow.java
@@ -21,13 +21,12 @@
import com.predic8.membrane.core.interceptor.oauth2.parameter.ClaimsParameter;
import com.predic8.membrane.core.interceptor.oauth2.request.NoResponse;
import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.JwtGenerator;
+import org.jose4j.lang.JoseException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Map;
-import org.jose4j.lang.JoseException;
-
public class PasswordFlow extends TokenRequest {
public PasswordFlow(OAuth2AuthorizationServerInterceptor authServer, Exchange exc) throws Exception {
@@ -47,6 +46,20 @@ protected Response processWithParameters() throws Exception {
if(!verifyClientThroughParams())
return OAuth2Util.createParameterizedJsonErrorResponse("error","unauthorized_client");
+ Client client;
+ try {
+ synchronized (authServer.getClientList()) {
+ client = authServer.getClientList().getClient(getClientId());
+ }
+ } catch (Exception e) {
+ return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_client");
+ }
+
+ String grantTypes = client.getGrantTypes();
+ if (!grantTypes.contains(getGrantType())) {
+ return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_grant_type");
+ }
+
Map userParams = verifyUserThroughParams();
if(userParams == null)
return OAuth2Util.createParameterizedJsonErrorResponse("error","access_denied");
@@ -62,21 +75,6 @@ protected Response processWithParameters() throws Exception {
session.getUserAttributes().putAll(userParams);
}
authServer.getSessionFinder().addSessionForToken(token,session);
-
- Client client;
- try {
- synchronized (authServer.getClientList()) {
- client = authServer.getClientList().getClient(getClientId());
- }
- } catch (Exception e) {
- return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_client");
- }
-
- String grantTypes = client.getGrantTypes();
- if (!grantTypes.contains(getGrantType())) {
- return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_grant_type");
- }
-
authServer.getSessionFinder().addSessionForRefreshToken(refreshToken, session);
if (authServer.isIssueNonSpecIdTokens() && OAuth2Util.isOpenIdScope(scope)) {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/RefreshTokenFlow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/RefreshTokenFlow.java
index 9b21d1f225..d15c847501 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/RefreshTokenFlow.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/request/tokenrequest/RefreshTokenFlow.java
@@ -21,15 +21,17 @@
import com.predic8.membrane.core.interceptor.oauth2.parameter.ClaimsParameter;
import com.predic8.membrane.core.interceptor.oauth2.request.NoResponse;
import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.JwtGenerator;
+import org.jose4j.lang.JoseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Map;
import java.util.NoSuchElementException;
-import org.jose4j.lang.JoseException;
-
public class RefreshTokenFlow extends TokenRequest {
+ private static final Logger log = LoggerFactory.getLogger(RefreshTokenFlow.class);
public RefreshTokenFlow(OAuth2AuthorizationServerInterceptor authServer, Exchange exc) throws Exception {
super(authServer, exc);
@@ -54,6 +56,7 @@ protected Response processWithParameters() throws Exception {
username = authServer.getRefreshTokenGenerator().getUsername(getRefreshToken());
additionalClaims = authServer.getRefreshTokenGenerator().getAdditionalClaims(getRefreshToken());
}catch(NoSuchElementException ex){
+ log.info("Refresh token not accepted: token could not be resolved to a user.");
return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_request");
}
@@ -90,12 +93,16 @@ protected Response processWithParameters() throws Exception {
SessionManager.Session session = authServer.getSessionFinder().getSessionForRefreshToken(getRefreshToken());
if(session == null) {
// client sends unknown refresh token
+ log.info("Refresh token not accepted: no session found for the presented refresh token.");
return OAuth2Util.createParameterizedJsonErrorResponse("error", "invalid_grant");
}
synchronized(session) {
session.getUserAttributes().put(ACCESS_TOKEN, token);
}
authServer.getSessionFinder().addSessionForToken(token, session);
+ // Rotate: the presented refresh token is single-use (OAuth2 Security BCP), only
+ // the newly issued one stays valid.
+ authServer.getSessionFinder().removeSessionForRefreshToken(getRefreshToken());
authServer.getSessionFinder().addSessionForRefreshToken(refreshToken, session);
if (OAuth2Util.isOpenIdScope(scope)) {
idToken = createSignedIdToken(session, username, client);
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/BearerJwtTokenGenerator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/BearerJwtTokenGenerator.java
index 0befe7f265..066d34a8b1 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/BearerJwtTokenGenerator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/BearerJwtTokenGenerator.java
@@ -43,21 +43,24 @@
@MCElement(name = "bearerJwtToken")
public class BearerJwtTokenGenerator implements TokenGenerator {
- private static final Logger LOG = LoggerFactory.getLogger(BearerJwtTokenGenerator.class);
+
+ private static final Logger log = LoggerFactory.getLogger(BearerJwtTokenGenerator.class);
+
private final SecureRandom random = new SecureRandom();
private RsaJsonWebKey rsaJsonWebKey;
private JwtSessionManager.Jwk jwk;
private long expiration;
private boolean warningGeneratedKey = true;
+ private String issuer;
public void init(Router router) throws Exception {
if (jwk == null) {
rsaJsonWebKey = generateKey();
if (warningGeneratedKey)
- LOG.warn("bearerJwtToken uses a generated key ('{}'). Sessions of this instance will not be compatible " +
+ log.warn("bearerJwtToken uses a generated key ('{}'). Sessions of this instance will not be compatible " +
"with sessions of other (e.g. restarted) instances. To solve this, write the JWK into a file and " +
- "reference it using .",
+ "reference it using bearerJwtToken/jwk/location: ...",
rsaJsonWebKey.toJson(JsonWebKey.OutputControlLevel.INCLUDE_PRIVATE));
} else {
rsaJsonWebKey = new RsaJsonWebKey(JsonUtil.parseJson(jwk.get(router.getResolverMap(), resolveBaseLocation(this, router))));
@@ -77,15 +80,26 @@ public String getTokenType() {
return "Bearer";
}
+ @Override
+ public void setIssuer(String issuer) {
+ this.issuer = issuer;
+ }
+
@Override
public String getToken(String username, String clientId, String clientSecret, Map additionalClaims) {
JwtClaims claims = new JwtClaims();
claims.setSubject(username);
claims.setClaim("clientId", clientId);
+ if (issuer != null)
+ claims.setIssuer(issuer);
if (expiration != 0)
claims.setExpirationTimeMinutesInTheFuture(expiration / 60.0f);
if (additionalClaims != null)
additionalClaims.forEach(claims::setClaim);
+ // Set last so a stale jti from additionalClaims cannot survive: every token must be
+ // unique (RFC 9068 requires jti), otherwise e.g. refresh token rotation is a no-op
+ // when two tokens with identical claims are issued within the same second.
+ claims.setGeneratedJwtId();
JsonWebSignature jws = new JsonWebSignature();
jws.setPayload(claims.toJson());
jws.setKey(rsaJsonWebKey.getRsaPrivateKey());
@@ -127,7 +141,7 @@ public Map getAdditionalClaims(String token) throws NoSuchElemen
}
private boolean isNormalClaim(String key) {
- return "sub".equals(key) || "clientId".equals(key) || "exp".equals(key);
+ return "sub".equals(key) || "clientId".equals(key) || "exp".equals(key) || "iss".equals(key) || "jti".equals(key);
}
@Override
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/TokenGenerator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/TokenGenerator.java
index f9e6c9dc9b..31153c9a8a 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/TokenGenerator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokengenerators/TokenGenerator.java
@@ -21,6 +21,14 @@
public interface TokenGenerator {
void init(Router router) throws Exception;
+ /**
+ * Sets the issuer that generated tokens should carry as their "iss" claim. Token generators that
+ * cannot carry claims (e.g. opaque tokens) ignore this.
+ */
+ default void setIssuer(String issuer) {
+ // no-op by default
+ }
+
/**
* @return the token type used, probably "Bearer".
*/
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokenvalidation/OAuth2TokenValidatorInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokenvalidation/OAuth2TokenValidatorInterceptor.java
index 708433f7d2..842ea29c36 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokenvalidation/OAuth2TokenValidatorInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/tokenvalidation/OAuth2TokenValidatorInterceptor.java
@@ -18,6 +18,8 @@
import com.predic8.membrane.core.http.*;
import com.predic8.membrane.core.interceptor.*;
import com.predic8.membrane.core.transport.http.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.net.*;
@@ -28,6 +30,8 @@
@MCElement(name="tokenValidator")
public class OAuth2TokenValidatorInterceptor extends AbstractInterceptor {
+ private static final Logger log = LoggerFactory.getLogger(OAuth2TokenValidatorInterceptor.class);
+
private String endpoint;
private HttpClient client;
@@ -69,6 +73,7 @@ private boolean callExchangeAndCheckFor200(Exchange e) throws Exception {
}
private void setResponseToBadRequest(Exchange exc) {
+ log.info("Access token not accepted: validation endpoint {} did not return 200.", endpoint);
exc.setResponse(Response.badRequest().build());
}
diff --git a/core/src/main/java/com/predic8/membrane/core/security/JWTSecurityScheme.java b/core/src/main/java/com/predic8/membrane/core/security/JWTSecurityScheme.java
index 55c95a4e60..9ab75f6ebd 100644
--- a/core/src/main/java/com/predic8/membrane/core/security/JWTSecurityScheme.java
+++ b/core/src/main/java/com/predic8/membrane/core/security/JWTSecurityScheme.java
@@ -13,23 +13,26 @@
limitations under the License. */
package com.predic8.membrane.core.security;
-import java.util.*;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
public class JWTSecurityScheme extends AbstractSecurityScheme {
/**
- * TODO
- * @param jwt JSON Web Token
+ * @param jwt claims of the validated JSON Web Token; the value of the {@code scopesClaim}
+ * entry may be a space separated string or a list
+ * @param scopesClaim name of the claim holding the scopes, e.g. "scp" (Microsoft Entra ID)
+ * or "scope" (RFC 9068)
*/
- public JWTSecurityScheme(Map jwt) {
- var scopes = jwt.get("scp");
- if (scopes != null) {
- if (scopes instanceof String scopeString) {
- this.scopes = new HashSet<>(Arrays.asList(scopeString.split(" +")));
- }
- if (scopes instanceof List scopeList) {
- this.scopes = new HashSet<>(scopeList);
- }
+ public JWTSecurityScheme(Map jwt, String scopesClaim) {
+ var scopes = jwt.get(scopesClaim);
+ if (scopes instanceof String scopeString) {
+ this.scopes = new HashSet<>(Arrays.asList(scopeString.split(" +")));
+ }
+ if (scopes instanceof List> scopeList) {
+ this.scopes = new HashSet<>(scopeList.stream().map(Object::toString).toList());
}
}
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorTest.java
index f101593c95..addfb66f8d 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptorTest.java
@@ -34,7 +34,6 @@
import java.util.Map;
import java.util.stream.Stream;
-import static com.predic8.membrane.core.interceptor.jwt.JwtAuthInterceptor.ERROR_JWT_INVALID_SIGNATURE;
import static org.junit.jupiter.api.Assertions.*;
public class JwtAuthInterceptorTest{
@@ -43,11 +42,14 @@ public class JwtAuthInterceptorTest{
public static final String SUB_CLAIM_CONTENT = "Till, der fleissige Programmierer";
private static final String AUDIENCE = "AusgestelltFuer";
private static final String TENANT_ID = "Tenant12345";
+ private static final String ISSUER = "https://auth.example.com";
public static Stream> data() throws Exception {
return Stream.of(happyPath(),
wrongAudience(),
wrongTenantId(),
+ wrongIssuer(),
+ missingIssuer(),
manipulatedSignature(),
unknownKey(),
wrongKId(),
@@ -116,8 +118,7 @@ private static TestData unknownKeyWithCorrectKid() {
(Exchange exc) -> {
assertTrue(exc.getResponse().isUserError());
assertNull(exc.getProperties().get("jwt"));
- var detail = (String) unpackBody(exc).get("detail");
- assertTrue(detail.startsWith(ERROR_JWT_INVALID_SIGNATURE));
+ assertEquals(JwtAuthInterceptor.ERROR_JWT_INVALID_SIGNATURE, unpackBody(exc).get("detail"));
}
);
}
@@ -169,8 +170,7 @@ private static TestData manipulatedSignature() {
(Exchange exc) -> {
assertTrue(exc.getResponse().isUserError());
assertNull(exc.getProperties().get("jwt"));
- var detail = (String) unpackBody(exc).get("detail");
- assertTrue(detail.startsWith(ERROR_JWT_INVALID_SIGNATURE));
+ assertEquals(JwtAuthInterceptor.ERROR_JWT_INVALID_SIGNATURE, unpackBody(exc).get("detail"));
}
);
}
@@ -185,8 +185,7 @@ private static TestData wrongAudience() {
(Exchange exc) -> {
assertTrue(exc.getResponse().isUserError());
assertNull(exc.getProperties().get("jwt"));
- String detail = (String) unpackBody(exc).get("detail");
- assertTrue(detail.startsWith(JwtAuthInterceptor.ERROR_VALIDATION_FAILED));
+ assertTrue(unpackBody(exc).get("detail").toString().contains("Expected AusgestelltFuer as an aud value"));
}
);
}
@@ -201,8 +200,37 @@ private static TestData wrongTenantId() {
(Exchange exc) -> {
assertTrue(exc.getResponse().isUserError());
assertNull(exc.getProperties().get("jwt"));
- var detail = (String) unpackBody(exc).get("detail");
- assertTrue(detail.startsWith(JwtAuthInterceptor.ERROR_VALIDATION_FAILED));
+ assertTrue(unpackBody(exc).get("detail").toString().contains("doesn't match the expected value 'Tenant12345'"));
+ }
+ );
+ }
+
+ private static TestData wrongIssuer() {
+ return new TestData(
+ "wrongIssuer",
+ (RsaJsonWebKey privateKey) -> new Request.Builder()
+ .get("")
+ .header("Authorization", "Bearer " + getSignedJwt(privateKey, getClaimsWithWrongIssuer()))
+ .buildExchange(),
+ (Exchange exc) -> {
+ assertTrue(exc.getResponse().isUserError());
+ assertNull(exc.getProperties().get("jwt"));
+ assertTrue(unpackBody(exc).get("detail").toString().contains("Issuer (iss) claim"));
+ }
+ );
+ }
+
+ private static TestData missingIssuer() {
+ return new TestData(
+ "missingIssuer",
+ (RsaJsonWebKey privateKey) -> new Request.Builder()
+ .get("")
+ .header("Authorization", "Bearer " + getSignedJwt(privateKey, getClaimsWithoutIssuer()))
+ .buildExchange(),
+ (Exchange exc) -> {
+ assertTrue(exc.getResponse().isUserError());
+ assertNull(exc.getProperties().get("jwt"));
+ assertTrue(unpackBody(exc).get("detail").toString().contains("Issuer (iss) claim"));
}
);
}
@@ -277,6 +305,7 @@ private JwtAuthInterceptor createInterceptor(RsaJsonWebKey publicOnly) {
interceptor.setJwks(jwks);
interceptor.setExpectedAud(AUDIENCE);
interceptor.setExpectedTid(TENANT_ID);
+ interceptor.setExpectedIss(ISSUER);
return interceptor;
}
@@ -301,6 +330,7 @@ private static JwtClaims createClaims(String audience, String tenantId){
claims.setSubject(SUB_CLAIM_CONTENT);
claims.setAudience(audience);
claims.setClaim("tid", tenantId);
+ claims.setIssuer(ISSUER);
return claims;
}
@@ -311,4 +341,16 @@ private static JwtClaims getClaimsWithWrongAudience() {
private static JwtClaims getClaimsWithWrongTenantId() {
return createClaims(AUDIENCE, TENANT_ID + "1");
}
+
+ private static JwtClaims getClaimsWithWrongIssuer() {
+ JwtClaims claims = createClaims(AUDIENCE, TENANT_ID);
+ claims.setIssuer(ISSUER + "1");
+ return claims;
+ }
+
+ private static JwtClaims getClaimsWithoutIssuer() {
+ JwtClaims claims = createClaims(AUDIENCE, TENANT_ID);
+ claims.unsetClaim("iss");
+ return claims;
+ }
}
diff --git a/core/src/test/java/com/predic8/membrane/core/security/JWTSecuritySchemeTest.java b/core/src/test/java/com/predic8/membrane/core/security/JWTSecuritySchemeTest.java
index 1d8d10f613..0d304062c4 100644
--- a/core/src/test/java/com/predic8/membrane/core/security/JWTSecuritySchemeTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/security/JWTSecuritySchemeTest.java
@@ -13,18 +13,24 @@
limitations under the License. */
package com.predic8.membrane.core.security;
-import com.predic8.membrane.core.exchange.*;
-import com.predic8.membrane.core.interceptor.jwt.*;
-import com.predic8.membrane.core.router.*;
-import org.jose4j.jwk.*;
-import org.jose4j.jws.*;
-import org.jose4j.jwt.*;
-import org.jose4j.lang.*;
-import org.junit.jupiter.api.*;
-
-import java.util.*;
-
-import static com.predic8.membrane.core.http.Request.*;
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.interceptor.jwt.Jwks;
+import com.predic8.membrane.core.interceptor.jwt.JwtAuthInterceptor;
+import com.predic8.membrane.core.router.DummyTestRouter;
+import org.jose4j.jwk.RsaJsonWebKey;
+import org.jose4j.jwk.RsaJwkGenerator;
+import org.jose4j.jws.AlgorithmIdentifiers;
+import org.jose4j.jws.JsonWebSignature;
+import org.jose4j.jwt.JwtClaims;
+import org.jose4j.lang.JoseException;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static com.predic8.membrane.core.http.Request.get;
+import static org.junit.jupiter.api.Assertions.assertEquals;
class JWTSecuritySchemeTest {
@@ -92,4 +98,30 @@ private static String getSignedJwt(RsaJsonWebKey privateKey, JwtClaims claims) t
return jws.getCompactSerialization();
}
+ @Test
+ void scopesFromString() {
+ assertEquals(Set.of("read", "write"), new JWTSecurityScheme(Map.of("scp", "read write"), "scp").getScopes());
+ }
+
+ @Test
+ void scopesFromList() {
+ assertEquals(Set.of("read", "write"), new JWTSecurityScheme(Map.of("scp", List.of("read", "write")), "scp").getScopes());
+ }
+
+ @Test
+ void scopesFromConfiguredScopeClaim() {
+ assertEquals(Set.of("read", "write"), new JWTSecurityScheme(Map.of("scope", "read write"), "scope").getScopes());
+ }
+
+ @Test
+ void onlyConfiguredClaimIsRead() {
+ var jwt = Map.of("scp", "fromScp", "scope", "fromScope");
+ assertEquals(Set.of("fromScp"), new JWTSecurityScheme(jwt, "scp").getScopes());
+ assertEquals(Set.of("fromScope"), new JWTSecurityScheme(jwt, "scope").getScopes());
+ }
+
+ @Test
+ void missingClaimMeansEmptyScopes() {
+ assertEquals(Set.of(), new JWTSecurityScheme(Map.of("sub", "john"), "scp").getScopes());
+ }
}
diff --git a/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/AuthServerWithoutUserDataProviderTest.java b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/AuthServerWithoutUserDataProviderTest.java
new file mode 100644
index 0000000000..0fc6cde191
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/AuthServerWithoutUserDataProviderTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.integration.withoutinternet.interceptor.oauth2;
+
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.http.Request;
+import com.predic8.membrane.core.interceptor.oauth2.ClaimList;
+import com.predic8.membrane.core.interceptor.oauth2.Client;
+import com.predic8.membrane.core.interceptor.oauth2.OAuth2AuthorizationServerInterceptor;
+import com.predic8.membrane.core.interceptor.oauth2.StaticClientList;
+import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.BearerJwtTokenGenerator;
+import com.predic8.membrane.core.router.TestRouter;
+import com.predic8.membrane.core.util.Util;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.predic8.membrane.annot.Constants.USERAGENT;
+import static com.predic8.membrane.core.http.Header.ACCEPT;
+import static com.predic8.membrane.core.http.Header.USER_AGENT;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_X_WWW_FORM_URLENCODED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * An oauth2authserver without a userDataProvider serves user-less flows
+ * (client_credentials); flows that need a user reject the request.
+ */
+public class AuthServerWithoutUserDataProviderTest {
+
+ private TestRouter router;
+ private OAuth2AuthorizationServerInterceptor oasi;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ router = new TestRouter();
+ router.start();
+ oasi = new OAuth2AuthorizationServerInterceptor() {
+ @Override
+ public String computeBasePath() {
+ return "";
+ }
+ };
+ BearerJwtTokenGenerator tokenGenerator = new BearerJwtTokenGenerator();
+ tokenGenerator.setWarningGeneratedKey(false);
+ oasi.setTokenGenerator(tokenGenerator);
+ oasi.setPath("/login/");
+ oasi.setIssuer("http://localhost:2001");
+ setClientList();
+ setClaimList();
+ oasi.init(router);
+ }
+
+ @AfterEach
+ void tearDown() {
+ router.stop();
+ }
+
+ @Test
+ void clientCredentialsGrantWorks() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=my-client&client_secret=secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertNotNull(Util.parseSimpleJSONResponse(exc.getResponse()).get("access_token"));
+ }
+
+ @Test
+ void passwordGrantIsRejected() throws Exception {
+ Exchange exc = tokenRequest("grant_type=password&username=john&password=secret"
+ + "&client_id=my-client&client_secret=secret");
+ assertEquals(400, exc.getResponse().getStatusCode());
+ assertEquals("access_denied", Util.parseSimpleJSONResponse(exc.getResponse()).get("error"));
+ }
+
+ private Exchange tokenRequest(String body) throws Exception {
+ Exchange exc = new Request.Builder()
+ .post("/oauth2/token")
+ .contentType(APPLICATION_X_WWW_FORM_URLENCODED)
+ .header(ACCEPT, APPLICATION_JSON)
+ .header(USER_AGENT, USERAGENT)
+ .body(body)
+ .buildExchange();
+ OAuth2TestUtil.makeExchangeValid(exc);
+ oasi.handleRequest(exc);
+ return exc;
+ }
+
+ private void setClientList() {
+ Client client = new Client("my-client", "secret", "http://localhost:2001/oauth2callback", "client_credentials,password");
+ StaticClientList cl = new StaticClientList();
+ cl.setClients(new ArrayList<>(List.of(client)));
+ oasi.setClientList(cl);
+ }
+
+ private void setClaimList() {
+ ClaimList cl = new ClaimList();
+ cl.setValue("sub");
+ cl.setScopes(new ArrayList<>());
+ oasi.setClaimList(cl);
+ }
+}
diff --git a/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/CredentialsFlowResourceTest.java b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/CredentialsFlowResourceTest.java
new file mode 100644
index 0000000000..3c25eb5067
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/CredentialsFlowResourceTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.integration.withoutinternet.interceptor.oauth2;
+
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.http.Request;
+import com.predic8.membrane.core.interceptor.authentication.session.StaticUserDataProvider;
+import com.predic8.membrane.core.interceptor.authentication.session.StaticUserDataProvider.UserConfig;
+import com.predic8.membrane.core.interceptor.oauth2.ClaimList;
+import com.predic8.membrane.core.interceptor.oauth2.Client;
+import com.predic8.membrane.core.interceptor.oauth2.OAuth2AuthorizationServerInterceptor;
+import com.predic8.membrane.core.interceptor.oauth2.StaticClientList;
+import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.BearerJwtTokenGenerator;
+import com.predic8.membrane.core.router.TestRouter;
+import com.predic8.membrane.core.util.Util;
+import org.jose4j.jwt.JwtClaims;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.predic8.membrane.annot.Constants.USERAGENT;
+import static com.predic8.membrane.core.http.Header.ACCEPT;
+import static com.predic8.membrane.core.http.Header.USER_AGENT;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_X_WWW_FORM_URLENCODED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Tests the RFC 8707 "resource" parameter and the per-client resource allowlist
+ * in the client_credentials grant.
+ */
+public class CredentialsFlowResourceTest {
+
+ private TestRouter router;
+ private OAuth2AuthorizationServerInterceptor oasi;
+ private BearerJwtTokenGenerator tokenGenerator;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ router = new TestRouter();
+ router.start();
+ oasi = new OAuth2AuthorizationServerInterceptor() {
+ @Override
+ public String computeBasePath() {
+ return "";
+ }
+ };
+ tokenGenerator = new BearerJwtTokenGenerator();
+ tokenGenerator.setWarningGeneratedKey(false);
+ oasi.setTokenGenerator(tokenGenerator);
+ OAuth2AuthorizationServerInterceptor.RefreshTokenConfig refreshTokenConfig = new OAuth2AuthorizationServerInterceptor.RefreshTokenConfig();
+ BearerJwtTokenGenerator refreshTokenGenerator = new BearerJwtTokenGenerator();
+ refreshTokenGenerator.setWarningGeneratedKey(false);
+ refreshTokenConfig.setTokenGenerator(refreshTokenGenerator);
+ oasi.setRefreshTokenConfig(refreshTokenConfig);
+ oasi.setIssueNonSpecRefreshTokens(true);
+ oasi.setLocation("src/test/resources/oauth2/loginDialog/dialog");
+ oasi.setConsentFile("src/test/resources/oauth2/consentFile.json");
+ oasi.setPath("/login/");
+ oasi.setIssuer("http://localhost:2001");
+ setUserDataProvider();
+ setClientList();
+ setClaimList();
+ oasi.init(router);
+ }
+
+ @AfterEach
+ void tearDown() {
+ router.stop();
+ }
+
+ @Test
+ void noResourceParamAndNoAllowlistIssuesTokenWithoutAud() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=unrestricted&client_secret=secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertFalse(accessTokenClaims(exc).hasAudience());
+ }
+
+ @Test
+ void accessTokenCarriesIssuer() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=unrestricted&client_secret=secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertEquals("http://localhost:2001", accessTokenClaims(exc).getIssuer());
+ }
+
+ @Test
+ void noResourceParamIssuesTokenWithAllAllowedResourcesAsAud() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=restricted&client_secret=secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertEquals(List.of("https://api.example.com", "https://billing.example.com"),
+ accessTokenClaims(exc).getAudience());
+ }
+
+ @Test
+ void requestedResourceBecomesAud() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=restricted&client_secret=secret"
+ + "&resource=https://api.example.com");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertEquals(List.of("https://api.example.com"), accessTokenClaims(exc).getAudience());
+ }
+
+ @Test
+ void resourceNotInAllowlistIsRejected() throws Exception {
+ assertInvalidTarget(tokenRequest("grant_type=client_credentials&client_id=restricted&client_secret=secret"
+ + "&resource=https://evil.example.com"));
+ }
+
+ @Test
+ void resourceForClientWithoutAllowlistIsRejected() throws Exception {
+ assertInvalidTarget(tokenRequest("grant_type=client_credentials&client_id=unrestricted&client_secret=secret"
+ + "&resource=https://api.example.com"));
+ }
+
+ @Test
+ void nonAbsoluteResourceUriIsRejected() throws Exception {
+ assertInvalidTarget(tokenRequest("grant_type=client_credentials&client_id=badlist&client_secret=secret"
+ + "&resource=api"));
+ }
+
+ @Test
+ void refreshedAccessTokenKeepsAud() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=restricted&client_secret=secret"
+ + "&resource=https://api.example.com");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ String refreshToken = Util.parseSimpleJSONResponse(exc.getResponse()).get("refresh_token");
+
+ Exchange refreshExc = tokenRequest("grant_type=refresh_token&refresh_token=" + refreshToken
+ + "&client_id=restricted&client_secret=secret");
+ assertEquals(200, refreshExc.getResponse().getStatusCode());
+ assertEquals(List.of("https://api.example.com"), accessTokenClaims(refreshExc).getAudience());
+ }
+
+ private Exchange tokenRequest(String body) throws Exception {
+ Exchange exc = new Request.Builder()
+ .post("/oauth2/token")
+ .contentType(APPLICATION_X_WWW_FORM_URLENCODED)
+ .header(ACCEPT, APPLICATION_JSON)
+ .header(USER_AGENT, USERAGENT)
+ .body(body)
+ .buildExchange();
+ OAuth2TestUtil.makeExchangeValid(exc);
+ oasi.handleRequest(exc);
+ return exc;
+ }
+
+ private JwtClaims accessTokenClaims(Exchange exc) throws Exception {
+ return tokenGenerator.verify(Util.parseSimpleJSONResponse(exc.getResponse()).get("access_token"));
+ }
+
+ private void assertInvalidTarget(Exchange exc) throws Exception {
+ assertEquals(400, exc.getResponse().getStatusCode());
+ assertEquals("invalid_target", Util.parseSimpleJSONResponse(exc.getResponse()).get("error"));
+ }
+
+ private void setClientList() {
+ Client restricted = new Client("restricted", "secret", "http://localhost:2001/oauth2callback", "client_credentials,refresh_token");
+ restricted.setResources("https://api.example.com https://billing.example.com");
+ Client unrestricted = new Client("unrestricted", "secret", "http://localhost:2001/oauth2callback", "client_credentials");
+ Client badlist = new Client("badlist", "secret", "http://localhost:2001/oauth2callback", "client_credentials");
+ badlist.setResources("api");
+ StaticClientList cl = new StaticClientList();
+ cl.setClients(new ArrayList<>(List.of(restricted, unrestricted, badlist)));
+ oasi.setClientList(cl);
+ }
+
+ private void setUserDataProvider() {
+ StaticUserDataProvider udp = new StaticUserDataProvider();
+ ArrayList users = new ArrayList<>();
+ users.add(new UserConfig("john", "password"));
+ udp.setUsers(users);
+ oasi.setUserDataProvider(udp);
+ }
+
+ private void setClaimList() {
+ ClaimList cl = new ClaimList();
+ cl.setValue("username email sub");
+ ArrayList scopes = new ArrayList<>();
+ scopes.add(new ClaimList.Scope("profile", "username email"));
+ cl.setScopes(scopes);
+ oasi.setClaimList(cl);
+ }
+}
diff --git a/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/CredentialsFlowScopeTest.java b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/CredentialsFlowScopeTest.java
new file mode 100644
index 0000000000..8643ba19a6
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/CredentialsFlowScopeTest.java
@@ -0,0 +1,175 @@
+/*
+ * 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.integration.withoutinternet.interceptor.oauth2;
+
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.http.Request;
+import com.predic8.membrane.core.interceptor.authentication.session.StaticUserDataProvider;
+import com.predic8.membrane.core.interceptor.authentication.session.StaticUserDataProvider.UserConfig;
+import com.predic8.membrane.core.interceptor.oauth2.ClaimList;
+import com.predic8.membrane.core.interceptor.oauth2.Client;
+import com.predic8.membrane.core.interceptor.oauth2.OAuth2AuthorizationServerInterceptor;
+import com.predic8.membrane.core.interceptor.oauth2.StaticClientList;
+import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.BearerJwtTokenGenerator;
+import com.predic8.membrane.core.router.TestRouter;
+import com.predic8.membrane.core.util.Util;
+import org.jose4j.jwt.JwtClaims;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.predic8.membrane.annot.Constants.USERAGENT;
+import static com.predic8.membrane.core.http.Header.ACCEPT;
+import static com.predic8.membrane.core.http.Header.USER_AGENT;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_X_WWW_FORM_URLENCODED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Tests the per-client scope allowlist in the client_credentials grant.
+ */
+public class CredentialsFlowScopeTest {
+
+ private TestRouter router;
+ private OAuth2AuthorizationServerInterceptor oasi;
+ private BearerJwtTokenGenerator tokenGenerator;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ router = new TestRouter();
+ router.start();
+ oasi = new OAuth2AuthorizationServerInterceptor() {
+ @Override
+ public String computeBasePath() {
+ return "";
+ }
+ };
+ tokenGenerator = new BearerJwtTokenGenerator();
+ tokenGenerator.setWarningGeneratedKey(false);
+ oasi.setTokenGenerator(tokenGenerator);
+ OAuth2AuthorizationServerInterceptor.RefreshTokenConfig refreshTokenConfig = new OAuth2AuthorizationServerInterceptor.RefreshTokenConfig();
+ BearerJwtTokenGenerator refreshTokenGenerator = new BearerJwtTokenGenerator();
+ refreshTokenGenerator.setWarningGeneratedKey(false);
+ refreshTokenConfig.setTokenGenerator(refreshTokenGenerator);
+ oasi.setRefreshTokenConfig(refreshTokenConfig);
+ oasi.setIssueNonSpecRefreshTokens(true);
+ oasi.setLocation("src/test/resources/oauth2/loginDialog/dialog");
+ oasi.setConsentFile("src/test/resources/oauth2/consentFile.json");
+ oasi.setPath("/login/");
+ oasi.setIssuer("http://localhost:2001");
+ setUserDataProvider();
+ setClientList();
+ setClaimList();
+ oasi.init(router);
+ }
+
+ @AfterEach
+ void tearDown() {
+ router.stop();
+ }
+
+ @Test
+ void noScopeParamGrantsAllAllowedScopes() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=scoped&client_secret=secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertEquals("orders:read orders:write", accessTokenClaims(exc).getClaimValue("scope", String.class));
+ assertEquals("orders:read orders:write", Util.parseSimpleJSONResponse(exc.getResponse()).get("scope"));
+ }
+
+ @Test
+ void requestedSubsetBecomesScopeClaim() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=scoped&client_secret=secret"
+ + "&scope=orders:read");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertEquals("orders:read", accessTokenClaims(exc).getClaimValue("scope", String.class));
+ assertEquals("orders:read", Util.parseSimpleJSONResponse(exc.getResponse()).get("scope"));
+ }
+
+ @Test
+ void scopeNotInAllowlistIsRejected() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=scoped&client_secret=secret"
+ + "&scope=admin");
+ assertEquals(400, exc.getResponse().getStatusCode());
+ assertEquals("invalid_scope", Util.parseSimpleJSONResponse(exc.getResponse()).get("error"));
+ }
+
+ @Test
+ void clientWithoutAllowlistPassesScopeThroughWithoutClaim() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=unscoped&client_secret=secret"
+ + "&scope=anything");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ assertFalse(accessTokenClaims(exc).getClaimsMap().containsKey("scope"));
+ assertEquals("anything", Util.parseSimpleJSONResponse(exc.getResponse()).get("scope"));
+ }
+
+ @Test
+ void refreshedAccessTokenKeepsScope() throws Exception {
+ Exchange exc = tokenRequest("grant_type=client_credentials&client_id=scoped&client_secret=secret"
+ + "&scope=orders:read");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ String refreshToken = Util.parseSimpleJSONResponse(exc.getResponse()).get("refresh_token");
+
+ Exchange refreshExc = tokenRequest("grant_type=refresh_token&refresh_token=" + refreshToken
+ + "&client_id=scoped&client_secret=secret");
+ assertEquals(200, refreshExc.getResponse().getStatusCode());
+ assertEquals("orders:read", accessTokenClaims(refreshExc).getClaimValue("scope", String.class));
+ }
+
+ private Exchange tokenRequest(String body) throws Exception {
+ Exchange exc = new Request.Builder()
+ .post("/oauth2/token")
+ .contentType(APPLICATION_X_WWW_FORM_URLENCODED)
+ .header(ACCEPT, APPLICATION_JSON)
+ .header(USER_AGENT, USERAGENT)
+ .body(body)
+ .buildExchange();
+ OAuth2TestUtil.makeExchangeValid(exc);
+ oasi.handleRequest(exc);
+ return exc;
+ }
+
+ private JwtClaims accessTokenClaims(Exchange exc) throws Exception {
+ return tokenGenerator.verify(Util.parseSimpleJSONResponse(exc.getResponse()).get("access_token"));
+ }
+
+ private void setClientList() {
+ Client scoped = new Client("scoped", "secret", "http://localhost:2001/oauth2callback", "client_credentials,refresh_token");
+ scoped.setScopes("orders:read orders:write");
+ Client unscoped = new Client("unscoped", "secret", "http://localhost:2001/oauth2callback", "client_credentials");
+ StaticClientList cl = new StaticClientList();
+ cl.setClients(new ArrayList<>(List.of(scoped, unscoped)));
+ oasi.setClientList(cl);
+ }
+
+ private void setUserDataProvider() {
+ StaticUserDataProvider udp = new StaticUserDataProvider();
+ ArrayList users = new ArrayList<>();
+ users.add(new UserConfig("john", "password"));
+ udp.setUsers(users);
+ oasi.setUserDataProvider(udp);
+ }
+
+ private void setClaimList() {
+ ClaimList cl = new ClaimList();
+ cl.setValue("username email sub");
+ ArrayList scopes = new ArrayList<>();
+ scopes.add(new ClaimList.Scope("profile", "username email"));
+ cl.setScopes(scopes);
+ oasi.setClaimList(cl);
+ }
+}
diff --git a/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/PasswordFlowClaimsTest.java b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/PasswordFlowClaimsTest.java
new file mode 100644
index 0000000000..416c2a23d2
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/integration/withoutinternet/interceptor/oauth2/PasswordFlowClaimsTest.java
@@ -0,0 +1,194 @@
+/*
+ * 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.integration.withoutinternet.interceptor.oauth2;
+
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.http.Request;
+import com.predic8.membrane.core.interceptor.authentication.session.StaticUserDataProvider;
+import com.predic8.membrane.core.interceptor.authentication.session.StaticUserDataProvider.UserConfig;
+import com.predic8.membrane.core.interceptor.oauth2.ClaimList;
+import com.predic8.membrane.core.interceptor.oauth2.Client;
+import com.predic8.membrane.core.interceptor.oauth2.OAuth2AuthorizationServerInterceptor;
+import com.predic8.membrane.core.interceptor.oauth2.StaticClientList;
+import com.predic8.membrane.core.interceptor.oauth2.tokengenerators.BearerJwtTokenGenerator;
+import com.predic8.membrane.core.router.TestRouter;
+import com.predic8.membrane.core.util.Util;
+import org.jose4j.jwt.JwtClaims;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.predic8.membrane.annot.Constants.USERAGENT;
+import static com.predic8.membrane.core.http.Header.ACCEPT;
+import static com.predic8.membrane.core.http.Header.USER_AGENT;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_X_WWW_FORM_URLENCODED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests that arbitrary user attributes ("aud", "scopes") configured on a
+ * staticUserDataProvider user are passed through into the JWT access token
+ * issued by the password grant, and survive a refresh.
+ */
+public class PasswordFlowClaimsTest {
+
+ private TestRouter router;
+ private OAuth2AuthorizationServerInterceptor oasi;
+ private BearerJwtTokenGenerator tokenGenerator;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ router = new TestRouter();
+ router.start();
+ oasi = new OAuth2AuthorizationServerInterceptor() {
+ @Override
+ public String computeBasePath() {
+ return "";
+ }
+ };
+ tokenGenerator = new BearerJwtTokenGenerator();
+ tokenGenerator.setWarningGeneratedKey(false);
+ oasi.setTokenGenerator(tokenGenerator);
+ OAuth2AuthorizationServerInterceptor.RefreshTokenConfig refreshTokenConfig = new OAuth2AuthorizationServerInterceptor.RefreshTokenConfig();
+ BearerJwtTokenGenerator refreshTokenGenerator = new BearerJwtTokenGenerator();
+ refreshTokenGenerator.setWarningGeneratedKey(false);
+ refreshTokenConfig.setTokenGenerator(refreshTokenGenerator);
+ oasi.setRefreshTokenConfig(refreshTokenConfig);
+ oasi.setLocation("src/test/resources/oauth2/loginDialog/dialog");
+ oasi.setConsentFile("src/test/resources/oauth2/consentFile.json");
+ oasi.setPath("/login/");
+ oasi.setIssuer("http://localhost:2001");
+ setUserDataProvider();
+ setClientList();
+ setClaimList();
+ oasi.init(router);
+ }
+
+ @AfterEach
+ void tearDown() {
+ router.stop();
+ }
+
+ @Test
+ void scopesAttributeBecomesJwtClaim() throws Exception {
+ Exchange exc = tokenRequest("grant_type=password&username=pickle&password=qwertz"
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ JwtClaims claims = accessTokenClaims(exc);
+ assertEquals("read write", claims.getClaimValue("scope", String.class));
+ assertEquals(List.of("demo-resource"), claims.getAudience());
+ }
+
+ @Test
+ void refreshWorksAfterCleanupSweep() throws Exception {
+ Exchange exc = tokenRequest("grant_type=password&username=pickle&password=qwertz"
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ String refreshToken = Util.parseSimpleJSONResponse(exc.getResponse()).get("refresh_token");
+
+ // The cleanup thread sweeps every 60s; a fresh, never-touched session must survive
+ // it, otherwise refresh tokens die within a minute of being issued.
+ oasi.getSessionManager().cleanup();
+
+ Exchange refreshExc = tokenRequest("grant_type=refresh_token&refresh_token=" + refreshToken
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, refreshExc.getResponse().getStatusCode());
+ }
+
+ @Test
+ void presentedRefreshTokenIsSingleUse() throws Exception {
+ Exchange exc = tokenRequest("grant_type=password&username=pickle&password=qwertz"
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ String refreshToken = Util.parseSimpleJSONResponse(exc.getResponse()).get("refresh_token");
+
+ Exchange firstRefresh = tokenRequest("grant_type=refresh_token&refresh_token=" + refreshToken
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, firstRefresh.getResponse().getStatusCode());
+
+ // Rotation: reusing the already-consumed refresh token must fail ...
+ Exchange reuse = tokenRequest("grant_type=refresh_token&refresh_token=" + refreshToken
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(400, reuse.getResponse().getStatusCode());
+ assertEquals("invalid_grant", Util.parseSimpleJSONResponse(reuse.getResponse()).get("error"));
+
+ // ... while the newly issued refresh token works.
+ String rotated = Util.parseSimpleJSONResponse(firstRefresh.getResponse()).get("refresh_token");
+ Exchange secondRefresh = tokenRequest("grant_type=refresh_token&refresh_token=" + rotated
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, secondRefresh.getResponse().getStatusCode());
+ }
+
+ @Test
+ void scopesAttributeSurvivesRefresh() throws Exception {
+ Exchange exc = tokenRequest("grant_type=password&username=pickle&password=qwertz"
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, exc.getResponse().getStatusCode());
+ String refreshToken = Util.parseSimpleJSONResponse(exc.getResponse()).get("refresh_token");
+
+ Exchange refreshExc = tokenRequest("grant_type=refresh_token&refresh_token=" + refreshToken
+ + "&client_id=demo-client&client_secret=demo-secret");
+ assertEquals(200, refreshExc.getResponse().getStatusCode());
+ JwtClaims claims = accessTokenClaims(refreshExc);
+ assertEquals("read write", claims.getClaimValue("scope", String.class));
+ assertEquals(List.of("demo-resource"), claims.getAudience());
+ }
+
+ private Exchange tokenRequest(String body) throws Exception {
+ Exchange exc = new Request.Builder()
+ .post("/oauth2/token")
+ .contentType(APPLICATION_X_WWW_FORM_URLENCODED)
+ .header(ACCEPT, APPLICATION_JSON)
+ .header(USER_AGENT, USERAGENT)
+ .body(body)
+ .buildExchange();
+ OAuth2TestUtil.makeExchangeValid(exc);
+ oasi.handleRequest(exc);
+ return exc;
+ }
+
+ private JwtClaims accessTokenClaims(Exchange exc) throws Exception {
+ return tokenGenerator.verify(Util.parseSimpleJSONResponse(exc.getResponse()).get("access_token"));
+ }
+
+ private void setClientList() {
+ Client demoClient = new Client("demo-client", "demo-secret", "http://localhost:2001/oauth2callback", "password,refresh_token");
+ StaticClientList cl = new StaticClientList();
+ cl.setClients(new ArrayList<>(List.of(demoClient)));
+ oasi.setClientList(cl);
+ }
+
+ private void setUserDataProvider() {
+ StaticUserDataProvider udp = new StaticUserDataProvider();
+ ArrayList users = new ArrayList<>();
+ UserConfig pickle = new UserConfig("pickle", "qwertz");
+ pickle.getAttributes().put("aud", "demo-resource");
+ pickle.getAttributes().put("scopes", "read write");
+ users.add(pickle);
+ udp.setUsers(users);
+ oasi.setUserDataProvider(udp);
+ }
+
+ private void setClaimList() {
+ ClaimList cl = new ClaimList();
+ cl.setValue("username email sub");
+ ArrayList scopes = new ArrayList<>();
+ scopes.add(new ClaimList.Scope("profile", "username email"));
+ cl.setScopes(scopes);
+ oasi.setClaimList(cl);
+ }
+}
diff --git a/distribution/conf/log4j2.xml b/distribution/conf/log4j2.xml
deleted file mode 100644
index f2aae692f2..0000000000
--- a/distribution/conf/log4j2.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/distribution/pom.xml b/distribution/pom.xml
index dcd532c135..0a30be0864 100644
--- a/distribution/pom.xml
+++ b/distribution/pom.xml
@@ -250,7 +250,7 @@
**/ExampleTestsWithoutInternet.java
- -Dfile.encoding=UTF-8
+ -Dfile.encoding=UTF-8 -Djdk.xml.maxGeneralEntitySizeLimit=0 -Djdk.xml.totalEntitySizeLimit=0
diff --git a/distribution/release-notes/0.0.0.md b/distribution/release-notes/0.0.0.md
deleted file mode 100644
index 5fea64ecc0..0000000000
--- a/distribution/release-notes/0.0.0.md
+++ /dev/null
@@ -1 +0,0 @@
-## Release Notes
diff --git a/distribution/release-notes/5.1.17.md b/distribution/release-notes/5.1.17.md
deleted file mode 100644
index d5d441d7eb..0000000000
--- a/distribution/release-notes/5.1.17.md
+++ /dev/null
@@ -1 +0,0 @@
-This is a test release, testing the automatic deployment. Please ignore.
\ No newline at end of file
diff --git a/distribution/release-notes/5.1.18.md b/distribution/release-notes/5.1.18.md
deleted file mode 100644
index d5d441d7eb..0000000000
--- a/distribution/release-notes/5.1.18.md
+++ /dev/null
@@ -1 +0,0 @@
-This is a test release, testing the automatic deployment. Please ignore.
\ No newline at end of file
diff --git a/distribution/release-notes/5.1.19.md b/distribution/release-notes/5.1.19.md
deleted file mode 100644
index d5d441d7eb..0000000000
--- a/distribution/release-notes/5.1.19.md
+++ /dev/null
@@ -1 +0,0 @@
-This is a test release, testing the automatic deployment. Please ignore.
\ No newline at end of file
diff --git a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/OAuth2APIExampleTest.java b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/OAuth2APIExampleTest.java
index b878ed9c95..a371d1cb4b 100644
--- a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/OAuth2APIExampleTest.java
+++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/test/OAuth2APIExampleTest.java
@@ -14,14 +14,19 @@
package com.predic8.membrane.examples.withoutinternet.test;
-import com.predic8.membrane.examples.util.*;
-import io.restassured.*;
-import io.restassured.filter.log.*;
-import org.junit.jupiter.api.*;
+import com.predic8.membrane.examples.util.BufferLogger;
+import com.predic8.membrane.examples.util.DistributionExtractingTestcase;
+import com.predic8.membrane.examples.util.Process2;
+import io.restassured.RestAssured;
+import io.restassured.filter.log.RequestLoggingFilter;
+import io.restassured.filter.log.ResponseLoggingFilter;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
-import java.io.*;
+import java.io.IOException;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class OAuth2APIExampleTest extends DistributionExtractingTestcase {
@@ -52,13 +57,14 @@ void stopMembrane() {
@Test
void testIt() throws Exception {
BufferLogger logger = new BufferLogger();
- // client.sh prints true when the script was successful. The logger waits for this string "true"
+ // "Got: " (with space) matches only the API-response echo line in client.sh, not "Got Token: ",
+ // preventing false early trigger when the bearer token happens to contain the substring "true".
try(Process2 ignored = new Process2.Builder()
.in(getExampleDir(getExampleDirName()))
.withWatcher(logger)
.script("client")
.withParameters("john password")
- .waitAfterStartFor("true")
+ .waitAfterStartFor("Got: ")
.start()) {
assertTrue(logger.contains("success"));
assertTrue(logger.contains("true"));
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2BasicsTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2BasicsTutorialTest.java
new file mode 100644
index 0000000000..a073d0e1d1
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2BasicsTutorialTest.java
@@ -0,0 +1,61 @@
+/* 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;
+
+import org.junit.jupiter.api.Test;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+
+public class OAuth2BasicsTutorialTest extends AbstractSecurityTutorialTest {
+
+ @Override
+ protected String getTutorialYaml() {
+ return "50-OAuth2-Basics.yaml";
+ }
+
+ @Test
+ void blocksWithoutTokenAndAcceptsIssuedToken() {
+ // @formatter:off
+ // 1) Without a token the gateway blocks the request.
+ given()
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(400);
+
+ // 2) Get an access token (client credentials, no user involved).
+ String token =
+ given()
+ .formParam("grant_type", "client_credentials")
+ .formParam("client_id", "abc")
+ .formParam("client_secret", "def")
+ .when()
+ .post("http://localhost:7007/oauth2/token")
+ .then()
+ .statusCode(200)
+ .extract().path("access_token");
+
+ // 3) With the token the gateway lets the request through.
+ given()
+ .header("Authorization", "Bearer " + token)
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .body(containsString("Protected resource accessed!"));
+ // @formatter:on
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2ClientCredentialsTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2ClientCredentialsTutorialTest.java
new file mode 100644
index 0000000000..5d5a2eb73c
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2ClientCredentialsTutorialTest.java
@@ -0,0 +1,77 @@
+/* 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;
+
+import org.junit.jupiter.api.Test;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+public class OAuth2ClientCredentialsTutorialTest extends AbstractSecurityTutorialTest {
+
+ @Override
+ protected String getTutorialYaml() {
+ return "51-OAuth2-Client-Credentials.yaml";
+ }
+
+ @Test
+ void issuesJwtWithClaimsAndValidatesIt() {
+ // @formatter:off
+ // 1) Without a token the API rejects the request.
+ given()
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(401);
+
+ // 2) Get a JWT; the granted scopes are echoed in the response.
+ String token =
+ given()
+ .formParam("grant_type", "client_credentials")
+ .formParam("client_id", "order-service")
+ .formParam("client_secret", "secret")
+ .when()
+ .post("http://localhost:7007/oauth2/token")
+ .then()
+ .statusCode(200)
+ .body("scope", equalTo("read write"))
+ .extract().path("access_token");
+
+ // 3) The API validates the JWT and sees the claims.
+ given()
+ .header("Authorization", "Bearer " + token)
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .body(containsString("\"client\": \"order-service\""))
+ .body(containsString("\"scope\": \"read write\""))
+ .body(containsString("\"aud\": \"order-api\""));
+
+ // 4) Scopes outside the client's allowlist are rejected.
+ given()
+ .formParam("grant_type", "client_credentials")
+ .formParam("client_id", "order-service")
+ .formParam("client_secret", "secret")
+ .formParam("scope", "admin")
+ .when()
+ .post("http://localhost:7007/oauth2/token")
+ .then()
+ .statusCode(400)
+ .body("error", equalTo("invalid_scope"));
+ // @formatter:on
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2ClientTokenRenewalTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2ClientTokenRenewalTutorialTest.java
new file mode 100644
index 0000000000..0ed6015f65
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2ClientTokenRenewalTutorialTest.java
@@ -0,0 +1,125 @@
+/* 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;
+
+import com.predic8.membrane.examples.util.BufferLogger;
+import com.predic8.membrane.examples.util.DistributionExtractingTestcase;
+import com.predic8.membrane.examples.util.Process2;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class OAuth2ClientTokenRenewalTutorialTest extends DistributionExtractingTestcase {
+
+ private static final String YAML = "53-OAuth2-Client-Token-Renewal.yaml";
+
+ /** The auth server logs this line whenever the gateway fetches a token. */
+ private static final Pattern TOKEN_FETCH = Pattern.compile("POST /oauth2/token");
+ /** The backend logs "Gateway forwarded token ..." for each forwarded request. */
+ private static final Pattern FORWARDED_TOKEN = Pattern.compile("Gateway forwarded token \\.\\.\\.(\\S+)");
+
+ protected Process2 process;
+ private final BufferLogger logger = new BufferLogger();
+
+ @Override
+ protected String getExampleDirName() {
+ return "../tutorials/security";
+ }
+
+ @Override
+ protected String getParameters() {
+ return "-c " + YAML;
+ }
+
+ /**
+ * Runs after {@code DistributionExtractingTestcase.init()} sets {@code baseDir}.
+ * Shortens the token lifetime from 60 s to 3 s so the test is quick. The client's
+ * cache window is then ~2 s (refresh buffer = expiry/10, min 1 s), leaving a
+ * comfortable margin so a reused token never validates right at its expiry.
+ * Membrane's console is captured so we can read token fetches and the forwarded
+ * token suffix from the log.
+ */
+ @BeforeEach
+ void startGateway() throws IOException, InterruptedException {
+ replaceInFile2(YAML, "expiration: 60", "expiration: 3");
+ process = startServiceProxyScript(logger);
+ }
+
+ @AfterEach
+ void stopGateway() {
+ if (process != null)
+ process.killScript();
+ }
+
+ @Test
+ void reusesCachedTokenThenRenewsAfterExpiry() throws InterruptedException {
+ // 1) First call: the gateway fetches a token, and the response never leaks it.
+ // @formatter:off
+ given()
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .body(containsString("Service accessed!"))
+ .body(not(containsString("Bearer")));
+ // @formatter:on
+ Thread.sleep(200);
+ int fetchesAfterFirstCall = countTokenFetches();
+ String firstSuffix = lastForwardedSuffix();
+ assertTrue(fetchesAfterFirstCall >= 1, "the gateway must fetch a token on the first call");
+
+ // 2) Immediate second call: the cached token is reused — no new fetch, same token.
+ given().when().get("http://localhost:2000").then().statusCode(200);
+ Thread.sleep(200);
+ assertEquals(fetchesAfterFirstCall, countTokenFetches(),
+ "an immediate second call must reuse the cached token (no new POST /oauth2/token)");
+ assertEquals(firstSuffix, lastForwardedSuffix(),
+ "the reused token must be identical");
+
+ // 3) After the cache window (~2 s) passes, the next call renews the token.
+ Thread.sleep(2500);
+ given().when().get("http://localhost:2000").then().statusCode(200);
+ Thread.sleep(200);
+ assertTrue(countTokenFetches() > fetchesAfterFirstCall,
+ "after expiry the gateway must fetch a new token (POST /oauth2/token)");
+ assertNotEquals(firstSuffix, lastForwardedSuffix(),
+ "after expiry the forwarded token must change");
+ }
+
+ private int countTokenFetches() {
+ int count = 0;
+ Matcher m = TOKEN_FETCH.matcher(logger.toString());
+ while (m.find())
+ count++;
+ return count;
+ }
+
+ private String lastForwardedSuffix() {
+ String suffix = null;
+ Matcher m = FORWARDED_TOKEN.matcher(logger.toString());
+ while (m.find())
+ suffix = m.group(1);
+ return suffix;
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2DistributedValidationTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2DistributedValidationTutorialTest.java
new file mode 100644
index 0000000000..a60a72c979
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2DistributedValidationTutorialTest.java
@@ -0,0 +1,92 @@
+/* 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;
+
+import com.predic8.membrane.examples.util.DistributionExtractingTestcase;
+import com.predic8.membrane.examples.util.Process2;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+
+/**
+ * Two-instance tutorial (54a + 54b): 54a issues signed JWTs and publishes its public
+ * keys at a JWKS endpoint, 54b validates those tokens by fetching the keys over HTTP.
+ * The issuer must be up before the validator starts, because jwtAuth resolves the
+ * JWKS at startup — hence starting the issuer first with waitForMembrane().
+ */
+public class OAuth2DistributedValidationTutorialTest extends DistributionExtractingTestcase {
+
+ @Override
+ protected String getExampleDirName() {
+ return "../tutorials/security";
+ }
+
+ private Process2 issuer;
+ private Process2 validator;
+
+ @BeforeEach
+ void startInstances() throws Exception {
+ issuer = new Process2.Builder().in(baseDir).script("membrane")
+ .withParameters("-c 54a-OAuth2-Distributed-Issuer.yaml").waitForMembrane().start();
+ validator = new Process2.Builder().in(baseDir).script("membrane")
+ .withParameters("-c 54b-OAuth2-Distributed-Validation.yaml").waitForMembrane().start();
+ }
+
+ @AfterEach
+ void stopInstances() {
+ if (validator != null)
+ validator.killScript();
+ if (issuer != null)
+ issuer.killScript();
+ }
+
+ @Test
+ void issuesJwtAndValidatesViaJwks() {
+ // @formatter:off
+ // 1) The protected API rejects requests without a token.
+ given()
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(401);
+
+ // 2) Get a signed JWT access token from the issuer (password grant).
+ String token =
+ given()
+ .formParam("grant_type", "password")
+ .formParam("username", "john")
+ .formParam("password", "password")
+ .formParam("client_id", "abc")
+ .formParam("client_secret", "def")
+ .when()
+ .post("http://localhost:7007/oauth2/token")
+ .then()
+ .statusCode(200)
+ .extract().path("access_token");
+
+ // 3) The validator accepts the token after fetching the issuer's public keys.
+ given()
+ .header("Authorization", "Bearer " + token)
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .body(containsString("Hello, john!"));
+ // @formatter:on
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2PasswordFlowTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2PasswordFlowTutorialTest.java
new file mode 100644
index 0000000000..126751b3d4
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/OAuth2PasswordFlowTutorialTest.java
@@ -0,0 +1,88 @@
+/* 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;
+
+import io.restassured.response.Response;
+import org.junit.jupiter.api.Test;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+
+public class OAuth2PasswordFlowTutorialTest extends AbstractSecurityTutorialTest {
+
+ @Override
+ protected String getTutorialYaml() {
+ return "52-OAuth2-Password-Flow.yaml";
+ }
+
+ @Test
+ void blocksRequestsWithoutTokenAndGrantsAccessWithUserLogin() {
+ // @formatter:off
+ // 1) Without a token the API rejects the request.
+ given()
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(401);
+
+ // 2) The user logs in through the client application.
+ Response login = given()
+ .formParam("grant_type", "password")
+ .formParam("username", "john")
+ .formParam("password", "password")
+ .formParam("client_id", "abc")
+ .formParam("client_secret", "def")
+ .when()
+ .post("http://localhost:7007/oauth2/token")
+ .then()
+ .statusCode(200)
+ .extract().response();
+ String token = login.path("access_token");
+ String refreshToken = login.path("refresh_token");
+
+ // 3) The API validates the JWT and sees the user's claims.
+ given()
+ .header("Authorization", "Bearer " + token)
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .body(containsString("\"user\": \"john\""))
+ .body(containsString("\"scope\": \"read write\""));
+
+ // 4) The refresh token yields a fresh access token — no password needed —
+ // and the new token still carries the user's claims.
+ String refreshedToken = given()
+ .formParam("grant_type", "refresh_token")
+ .formParam("refresh_token", refreshToken)
+ .formParam("client_id", "abc")
+ .formParam("client_secret", "def")
+ .when()
+ .post("http://localhost:7007/oauth2/token")
+ .then()
+ .statusCode(200)
+ .extract().path("access_token");
+
+ given()
+ .header("Authorization", "Bearer " + refreshedToken)
+ .when()
+ .get("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .body(containsString("\"user\": \"john\""))
+ .body(containsString("\"scope\": \"read write\""));
+ // @formatter:on
+ }
+}
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/JwtSigningTutorialTest.java
similarity index 92%
rename from distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/IssuingAndValidatingJwtsTutorialTest.java
rename to distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/JwtSigningTutorialTest.java
index da6920a509..2b395dd759 100644
--- a/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/IssuingAndValidatingJwtsTutorialTest.java
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/JwtSigningTutorialTest.java
@@ -20,11 +20,11 @@
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
-public class IssuingAndValidatingJwtsTutorialTest extends AbstractSecurityJwtTutorialTest {
+public class JwtSigningTutorialTest extends AbstractSecurityJwtTutorialTest {
@Override
protected String getTutorialYaml() {
- return "50-Issuing-and-Validating-JWTs.yaml";
+ return "41-JWT-Signing.yaml";
}
@Test
@@ -69,7 +69,7 @@ void issuesTokenAndProtectsResource() {
.then()
.statusCode(200)
.body("client", equalTo("alice"))
- .body("scopes", equalTo("read write"));
+ .body("scope", equalTo("read write"));
// @formatter:on
}
}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/RequestingTokenTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/RequestingTokenTutorialTest.java
new file mode 100644
index 0000000000..e0a5ee1f96
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/jwt/RequestingTokenTutorialTest.java
@@ -0,0 +1,85 @@
+/* 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.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.InetSocketAddress;
+import java.net.Socket;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Verifies that the hosted Membrane demo at api.predic8.de still behaves as the
+ * 40-JWT-Requesting-Token.md walkthrough documents. That tutorial has no local
+ * config — it drives the public demo directly — so this test needs internet, not a
+ * running gateway. It exists to catch drift if the hosted demo ever changes.
+ * Skipped (not failed) when api.predic8.de is unreachable, so offline runs stay green.
+ */
+public class RequestingTokenTutorialTest {
+
+ private static final String TOKEN_ENDPOINT = "https://api.predic8.de/demo/oauth2/token";
+ private static final String RESOURCE = "https://api.predic8.de/demo/resource";
+
+ @BeforeAll
+ static void requiresInternet() {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("api.predic8.de", 443), 3000);
+ } catch (Exception e) {
+ assumeTrue(false, "api.predic8.de is not reachable - skipping hosted-demo test");
+ }
+ }
+
+ @Test
+ void requestsTokenAndCallsProtectedResource() {
+ // @formatter:off
+ // 1) The client credentials grant returns a bearer token (step 1 of the tutorial).
+ String token =
+ given()
+ .auth().preemptive().basic("my-client", "my-secret")
+ .formParam("grant_type", "client_credentials")
+ .when()
+ .post(TOKEN_ENDPOINT)
+ .then()
+ .statusCode(200)
+ .body("token_type", equalTo("bearer"))
+ .body("expires_in", equalTo(300))
+ .body("access_token", notNullValue())
+ .extract().path("access_token");
+
+ // 2) The token grants access to the protected resource (step 3 of the tutorial).
+ given()
+ .header("Authorization", "Bearer " + token)
+ .when()
+ .get(RESOURCE)
+ .then()
+ .statusCode(200)
+ .body("success", equalTo(true))
+ .body("sub", equalTo("my-client"))
+ .body("scope", equalTo("read write"));
+
+ // 3) Without the token the request is rejected.
+ given()
+ .when()
+ .get(RESOURCE)
+ .then()
+ .statusCode(401);
+ // @formatter:on
+ }
+}
diff --git a/distribution/tutorials/security/40-JWT-Requesting-Token.md b/distribution/tutorials/security/40-JWT-Requesting-Token.md
new file mode 100644
index 0000000000..826077f4e9
--- /dev/null
+++ b/distribution/tutorials/security/40-JWT-Requesting-Token.md
@@ -0,0 +1,60 @@
+# Requesting a JWT
+
+This tutorial shows how a client obtains a JSON Web Token from an authorization server and uses
+it to call a protected API.
+
+You request a token, inspect it, and send it as a Bearer token on a request.
+The gateway that *issues and validates* those tokens is covered in the following tutorials.
+Here you only consume them.
+
+By the end you will know how to get an access token via the OAuth2 client credentials
+grant, read its claims, and authenticate an API call with it.
+
+No setup required, just `curl`. The tutorial uses the public demo at `https://api.predic8.de`.
+
+Based on: https://www.membrane-api.io/jwt/jwt-api-authentication-authorization-tutorial.html
+
+## 1. Request a token
+
+```sh
+curl -v https://api.predic8.de/demo/oauth2/token -u "my-client:my-secret" -d "grant_type=client_credentials"
+```
+
+Response body:
+
+```json
+{"access_token":"eyJ0eXAiOiJKV1Qi...","token_type":"bearer","expires_in":300}
+```
+
+## 2. Inspect the token
+
+Paste the `access_token` into . A JWT has three parts separated by dots:
+
+`header.payload.signature`
+
+The **payload** carries the claims that describe the token. Who it is for, what it may do, and how long it is valid. For this demo token they are:
+
+| Claim | Meaning | Example |
+|---------|--------------------------------------|-----------------|
+| `sub` | subject (the client id) | `my-client` |
+| `aud` | audience (the API this token is for) | `demo-resource` |
+| `scope` | permissions granted | `read write` |
+| `iat` | issued-at (Unix time) | `1782893176` |
+| `exp` | expiry (Unix time, `iat` + 300s) | `1782893476` |
+| `nbf` | not valid before (Unix time) | `1782893056` |
+
+
+## 3. Call the protected resource
+
+```sh
+curl -v https://api.predic8.de/demo/resource -H "Authorization: Bearer eyJ0eXAiOiJKV1Qi..."
+```
+
+```json
+{ "success": true, "user": "my-client", "scopes": "read write" }
+```
+
+## Next
+
+Continue with [41-JWT-Signing.yaml](41-JWT-Signing.yaml)
+where Membrane issues and validates the tokens itself.
diff --git a/distribution/tutorials/security/40-Requesting-a-JWT.md b/distribution/tutorials/security/40-Requesting-a-JWT.md
deleted file mode 100644
index 85277cc599..0000000000
--- a/distribution/tutorials/security/40-Requesting-a-JWT.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# 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/41-JWT-Signing.yaml
similarity index 50%
rename from distribution/tutorials/security/50-Issuing-and-Validating-JWTs.yaml
rename to distribution/tutorials/security/41-JWT-Signing.yaml
index a89cfc5c54..520298713e 100644
--- a/distribution/tutorials/security/50-Issuing-and-Validating-JWTs.yaml
+++ b/distribution/tutorials/security/41-JWT-Signing.yaml
@@ -1,29 +1,30 @@
# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
#
-# Tutorial: Issuing and Validating JWTs with Membrane
+# Tutorial: Signing and Validating JWTs
#
-# Membrane acts as both token server and protected resource - no external server needed.
+# There are two ways in Membrane to issue JWTs:
+# - by a built-in OAuth token endpoint (as described in 51-OAuth2-Client-Credentials.yaml),
+# - using the jwtSign interceptor as described here
#
-# 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"
+# The jwtSign interceptor is a flexible and simple alternative to OAuth.
+# Basically, it takes a JSON document from the message body and signs it with a private key.
#
-# {"access_token":"eyJ0eXAiOiJKV1Qi...","token_type":"bearer","expires_in":300}
+# With jwtSign you can:
+# - Set up a simple endpoint that issues JWTs
+# - Exchange a token for a different token (e.g. Basic Auth with a JWT, a JWT for a different JWT, ...)
#
-# 3.) Paste the token into https://jwt.io - notice sub, aud, scopes, exp.
+# 1.) Start Membrane:
+# ./membrane.sh -c 41-JWT-Signing.yaml
#
-# 4.) Call the resource without a token (rejected):
-# curl -i localhost:2000/resource
+# 2.) Request a token with Basic Auth:
+# curl -v localhost:2000/token -u "alice:alice-secret"
#
-# 5.) Call it with the token:
-# curl localhost:2000/resource -H "Authorization: Bearer eyJ0eXAiOiJKV1Qi..."
+# {"access_token":"eyJ0eXAiOiJKV1Qi...","token_type":"bearer","expires_in":300}
#
-# {"client":"alice","scopes":"read write"}
+# 3.) Call the resource with the token:
+# curl -v localhost:2000/resource -H "Authorization: Bearer eyJ0eXAiOiJKV1Qi..."
#
-# 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.
+# {"client":"alice","scope":"read write"}
api:
port: 2000
@@ -43,8 +44,9 @@ api:
{
"sub": ${user()},
"aud": "demo-resource",
- "scopes": "read write"
+ "scope": "read write"
}
+ # Signs the request body with the private key in jwk.json and puts it into the token property.
- jwtSign:
property: token
jwk:
@@ -59,6 +61,7 @@ api:
}
- return:
status: 200
+
---
api:
port: 2000
@@ -75,8 +78,15 @@ api:
contentType: application/json
src: |
{
- "client": ${property.jwt.get("sub")},
- "scopes": ${property.jwt.get("scopes")}
+ "client": ${property.jwt.sub},
+ "scope": ${property.jwt.scope}
}
- return:
status: 200
+
+# 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.
+#
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS. Otherwise, the login credentials and bearer tokens are
+# transmitted in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
\ No newline at end of file
diff --git a/distribution/tutorials/security/50-OAuth2-Basics.yaml b/distribution/tutorials/security/50-OAuth2-Basics.yaml
new file mode 100644
index 0000000000..3231b0f6b4
--- /dev/null
+++ b/distribution/tutorials/security/50-OAuth2-Basics.yaml
@@ -0,0 +1,62 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
+#
+# Tutorial: OAuth2 Basics
+#
+# Protect an API with OAuth2: a client fetches an access token from an
+# authorization server and sends it with each request. The gateway validates
+# every token before letting the request through.
+#
+# 1.) Start Membrane:
+# ./membrane.sh -c 50-OAuth2-Basics.yaml
+#
+# 2.) Get an access token:
+# curl -v -d "grant_type=client_credentials&client_id=abc&client_secret=def" localhost:7007/oauth2/token
+#
+# {"access_token":"","token_type":"Bearer"}
+#
+# 3.) Call the API again with the token (replace ):
+# curl -v -H "Authorization: Bearer " localhost:2000
+#
+# Protected resource accessed!
+
+api:
+ name: Authorization Server
+ port: 7007
+ flow:
+ - beautifier: {}
+ - oauth2authserver:
+ issuer: http://localhost:7007
+ staticClientList:
+ clients:
+ - clientId: abc
+ clientSecret: def
+ bearerToken: {}
+ claims:
+ value: sub
+
+---
+api:
+ name: Protected API
+ port: 2000
+ flow:
+ # Asks the authorization server on every request whether the token is valid.
+ - tokenValidator:
+ endpoint: http://localhost:7007/oauth2/userinfo
+ - static:
+ src: Protected resource accessed!
+ - return:
+ status: 200
+
+# Using an external authorization server
+#
+# tokenValidator works with any OAuth2 provider (Keycloak, Microsoft Entra ID,
+# Google, ...). Most providers publish their endpoints at
+# https:///.well-known/openid-configuration
+# Point `endpoint` above at the provider's userinfo_endpoint, e.g.:
+# Keycloak https:///realms//protocol/openid-connect/userinfo
+# Entra ID https://graph.microsoft.com/oidc/userinfo
+# Google https://openidconnect.googleapis.com/v1/userinfo
+
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS — otherwise OAuth2 transmits credentials and bearer
+# tokens in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
diff --git a/distribution/tutorials/security/51-OAuth2-Client-Credentials.yaml b/distribution/tutorials/security/51-OAuth2-Client-Credentials.yaml
new file mode 100644
index 0000000000..3825c29a2d
--- /dev/null
+++ b/distribution/tutorials/security/51-OAuth2-Client-Credentials.yaml
@@ -0,0 +1,79 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
+#
+# Tutorial: OAuth2 Client Credentials Flow
+#
+# Machine-to-machine API access: a service authenticates with its own client ID
+# and secret. Same grant as tutorial 50, but the token is now a signed JWT that
+# the gateway validates without contacting the authorization server. Its claims
+# specify which API it may call (aud) and what it may do there (scope).
+#
+# 1.) Start Membrane:
+# ./membrane.sh -c 51-OAuth2-Client-Credentials.yaml
+#
+# 2.) Get an access token:
+# curl -v -s -d "grant_type=client_credentials&client_id=order-service&client_secret=secret" localhost:7007/oauth2/token
+#
+# {"access_token":"eyJ...","token_type":"Bearer","expires_in":600,"scope":"read write"}
+#
+# 3.) Decode the token's payload (or paste the token into https://jwt.io):
+# echo "" | cut -d. -f2 | base64 -d
+#
+# {"sub":"order-service","clientId":"order-service","iss":"http://localhost:7007","exp":...,"aud":"order-api","scope":"read write","jti":"..."}
+#
+# 4.) Call the API with the token (replace ):
+# curl -v -H "Authorization: Bearer " localhost:2000
+#
+# { "client": "order-service", "scope": "read write", "aud": "order-api" }
+#
+# 5.) Scopes outside the client's allowlist are rejected:
+# curl -d "grant_type=client_credentials&client_id=order-service&client_secret=secret&scope=admin" localhost:7007/oauth2/token
+#
+# {"error":"invalid_scope"}
+
+api:
+ name: Authorization Server
+ port: 7007
+ flow:
+ - beautifier: {}
+ - oauth2authserver:
+ issuer: http://localhost:7007
+ staticClientList:
+ clients:
+ - clientId: order-service
+ clientSecret: secret
+ grantTypes: client_credentials
+ # Audiences this client may request tokens for (RFC 8707).
+ resources: order-api
+ # Scopes this client may request.
+ scopes: read write
+ bearerJwtToken:
+ expiration: 600
+ claims:
+ value: sub iss aud
+
+---
+api:
+ name: Order API
+ port: 2000
+ flow:
+ # Validates signature, expiry, issuer and audience offline against the
+ # authorization server's public keys.
+ - jwtAuth:
+ expectedAud: order-api
+ expectedIss: http://localhost:7007
+ jwks:
+ jwksUris: http://localhost:7007/oauth2/certs
+ - template:
+ contentType: application/json
+ src: |
+ {
+ "client": ${property.jwt.sub},
+ "scope": ${property.jwt.scope},
+ "aud": ${property.jwt.aud}
+ }
+ - return:
+ status: 200
+
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS. Otherwise, OAuth2 transmits credentials and bearer
+# tokens in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
diff --git a/distribution/tutorials/security/52-OAuth2-Password-Flow.yaml b/distribution/tutorials/security/52-OAuth2-Password-Flow.yaml
new file mode 100644
index 0000000000..7f8f57eeed
--- /dev/null
+++ b/distribution/tutorials/security/52-OAuth2-Password-Flow.yaml
@@ -0,0 +1,100 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
+#
+# Tutorial: OAuth2 Password Flow
+#
+# Where client credentials (tutorial 51) authenticates only the application,
+# this flow adds a user: they log in with username and password through a
+# trusted client application. The issued JWT now carries two identities: the
+# user (sub) and the application (clientId).
+#
+# Note: OAuth 2.1 removes this grant. Use it for trusted first-party or
+# legacy apps; user-facing apps should use the authorization code flow.
+#
+# 1.) Start Membrane:
+# ./membrane.sh -c 52-OAuth2-Password-Flow.yaml
+#
+# Ignore the warnings.
+#
+# 2.) Log in as john and get an access token:
+# curl -v -s -d "grant_type=password&username=john&password=password&client_id=abc&client_secret=def" localhost:7007/oauth2/token
+#
+# Notice the access token and refresh token.
+#
+# {"access_token":"eyJ...","token_type":"Bearer","expires_in":600,"refresh_token":"..."}
+#
+# 3.) Decode the token's payload (or paste the token into https://jwt.io):
+# echo "" | cut -d. -f2 | base64 -d
+#
+# {"sub":"john","clientId":"abc","iss":"http://localhost:7007","exp":...,"aud":"order-api","scope":"read write","jti":"..."}
+#
+# Note the two identities: sub is the user, clientId the application.
+#
+# 4.) Call the API with the access token (replace ):
+# curl -v -H "Authorization: Bearer " localhost:2000
+#
+# { "user": "john", "scope": "read write" }
+#
+# 5.) When the access token expires, exchange the refresh token for a new one —
+# without asking the user for their password again (replace ):
+# curl -v -s -d "grant_type=refresh_token&refresh_token=&client_id=abc&client_secret=def" localhost:7007/oauth2/token
+#
+# {"access_token":"eyJ...","token_type":"Bearer","expires_in":600,"refresh_token":"..."}
+#
+# The new access token carries the same sub, aud and scope.
+
+api:
+ name: Authorization Server
+ port: 7007
+ flow:
+ - beautifier: {}
+ - oauth2authserver:
+ issuer: http://localhost:7007
+ staticUserDataProvider:
+ users:
+ - username: john
+ password: password
+ # Extra user attributes become claims of the user's tokens.
+ aud: order-api
+ scopes: read write
+ # The application logs in too: the password grant authenticates
+ # the client and the user.
+ staticClientList:
+ clients:
+ - clientId: abc
+ clientSecret: def
+ grantTypes: password,refresh_token
+ bearerJwtToken:
+ expiration: 600
+ # Issue JWT refresh tokens too, so the user's aud and scope claims are
+ # carried over when the access token is refreshed (step 5).
+ refresh:
+ bearerJwtToken:
+ expiration: 3600
+ claims:
+ value: sub iss aud
+
+---
+api:
+ name: Order API
+ port: 2000
+ flow:
+ # Validates signature, expiry, issuer and audience offline against the
+ # authorization server's public keys.
+ - jwtAuth:
+ expectedAud: order-api
+ expectedIss: http://localhost:7007
+ jwks:
+ jwksUris: http://localhost:7007/oauth2/certs
+ - template:
+ contentType: application/json
+ src: |
+ {
+ "user": ${property.jwt.sub},
+ "scope": ${property.jwt.scope}
+ }
+ - return:
+ status: 200
+
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS. Otherwise, OAuth2 transmits credentials and bearer
+# tokens in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
diff --git a/distribution/tutorials/security/53-OAuth2-Client-Token-Renewal.yaml b/distribution/tutorials/security/53-OAuth2-Client-Token-Renewal.yaml
new file mode 100644
index 0000000000..4b24674c56
--- /dev/null
+++ b/distribution/tutorials/security/53-OAuth2-Client-Token-Renewal.yaml
@@ -0,0 +1,99 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
+#
+# Tutorial: OAuth2 Client — Automatic Token Management
+#
+# The gateway can fetch and renew access tokens transparently.
+# Callers need no credentials. Membrane handles the full token lifecycle.
+# Tokens expire after 60 seconds; the gateway re-fetches automatically.
+#
+# 1.) Start Membrane:
+# ./membrane.sh -c 53-OAuth2-Client-Token-Renewal.yaml
+#
+# 2.) Call the gateway — no token needed from the caller:
+# curl -v localhost:2000
+#
+# Service accessed!
+#
+# Watch Membrane's console. On this first call the gateway fetches a token:
+# {api=Authorization Server} POST /oauth2/token
+# {api=Backend} Gateway forwarded token ...abc123
+#
+# 3.) Call it again right away:
+# curl -v localhost:2000
+#
+# No new "POST /oauth2/token" line appears. The gateway reuses its cached token
+# and the forwarded token is still the same as in step 2.
+#
+# 4.) Wait about a minute for the token to expire, then call once more:
+# curl -v localhost:2000
+#
+# Now "POST /oauth2/token" appears again in the log and the forwarded token ends differently.
+# Membrane renewed the token automatically.
+
+api:
+ name: Authorization Server
+ port: 7007
+ flow:
+ - request:
+ - log:
+ message: "${method} ${path}"
+ - oauth2authserver:
+ issuer: http://localhost:7007
+ staticUserDataProvider:
+ users:
+ - username: john
+ password: password
+ staticClientList:
+ clients:
+ - clientId: abc
+ clientSecret: def
+ callbackUrl: http://localhost:2000/oauth2callback
+ bearerJwtToken:
+ expiration: 60
+ claims:
+ value: sub
+ scopes:
+ - id: read
+ claims: sub
+
+---
+api:
+ name: Gateway
+ port: 2000
+ flow:
+ # Fetches a token from the auth server, caches it, and re-fetches before expiry.
+ - oauth2Client:
+ tokenUrl: http://localhost:7007/oauth2/token
+ clientId: abc
+ clientSecret: def
+ target:
+ url: http://localhost:3000
+
+---
+api:
+ name: Protected API
+ port: 3000
+ flow:
+ - tokenValidator:
+ endpoint: http://localhost:7007/oauth2/userinfo
+ target:
+ url: http://localhost:4000
+
+---
+api:
+ name: Backend
+ port: 4000
+ flow:
+ # The gateway added the bearer token when forwarding. Log only its last 6 chars
+ # so renewal is observable in Membrane's console without dumping the whole token.
+ - request:
+ - log:
+ message: "Gateway forwarded token ...${headers['Authorization'].substring(headers['Authorization'].length() - 6)}"
+ - static:
+ src: Service accessed!
+ - return:
+ status: 200
+
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS. Otherwise, OAuth2 transmits credentials and bearer
+# tokens in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
diff --git a/distribution/tutorials/security/54a-OAuth2-Distributed-Issuer.yaml b/distribution/tutorials/security/54a-OAuth2-Distributed-Issuer.yaml
new file mode 100644
index 0000000000..7cd2b6994c
--- /dev/null
+++ b/distribution/tutorials/security/54a-OAuth2-Distributed-Issuer.yaml
@@ -0,0 +1,61 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
+#
+# Tutorial: OAuth2 with a Separate Issuer and Validator (Part 1 of 2, Issuer)
+#
+# In a real deployment, the authorization server and the API gateway are separate
+# processes, often on separate machines. This tutorial runs them as two Membrane
+# instances: this one issues signed JWTs, 54b validates them. The validator needs
+# no shared secret. It fetches this server's public keys from its JWKS endpoint
+# (/oauth2/certs).
+#
+# Unlike the earlier one-instance tutorials, this server signs with a fixed key
+# from jwk.json, so its tokens stay valid across restarts.
+#
+# Run this in terminal 1:
+#
+# 1.) Start the authorization server:
+# ./membrane.sh -c 54a-OAuth2-Distributed-Issuer.yaml
+#
+# Ignore the warnings.
+#
+# 2.) Get a signed JWT access token:
+# curl -v -s -d "grant_type=password&username=john&password=password&client_id=abc&client_secret=def" localhost:7007/oauth2/token
+#
+# {"access_token":"eyJ...","token_type":"Bearer","expires_in":300}
+#
+# Paste the access_token into https://jwt.io
+# Note sub, clientId, iss, exp and the kid "membrane" in the header.
+#
+# 3.) Inspect the published keys (the validator fetches these):
+# curl -s localhost:7007/oauth2/certs
+#
+# Now start the validator in terminal 2 — see 54b-OAuth2-Distributed-Validation.yaml.
+
+api:
+ name: Authorization Server
+ port: 7007
+ flow:
+ - beautifier: {}
+ - oauth2authserver:
+ issuer: http://localhost:7007
+ staticUserDataProvider:
+ users:
+ - username: john
+ password: password
+ staticClientList:
+ clients:
+ - clientId: abc
+ clientSecret: def
+ grantTypes: password
+ # Sign with a fixed key from jwk.json, so tokens survive a restart. The
+ # matching public key is published at /oauth2/certs for validators to fetch.
+ bearerJwtToken:
+ expiration: 300
+ jwk:
+ location: jwk.json
+ claims:
+ value: sub iss
+
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS. Otherwise, OAuth2 transmits credentials and bearer
+# tokens in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
diff --git a/distribution/tutorials/security/54b-OAuth2-Distributed-Validation.yaml b/distribution/tutorials/security/54b-OAuth2-Distributed-Validation.yaml
new file mode 100644
index 0000000000..7c3278b4ef
--- /dev/null
+++ b/distribution/tutorials/security/54b-OAuth2-Distributed-Validation.yaml
@@ -0,0 +1,46 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
+#
+# Tutorial: OAuth2 with a Separate Issuer and Validator (Part 2 of 2, Validator)
+#
+# This SECOND Membrane instance protects an API by validating the JWTs issued by the
+# authorization server from 54a-OAuth2-Distributed-Issuer.yaml. It never sees a key
+# file: jwtAuth fetches the public keys over HTTP from the issuer's JWKS endpoint.
+#
+# 1.) Make sure 54a-OAuth2-Distributed-Issuer.yaml is already running in terminal 1.
+# Run this in terminal 2:
+# ./membrane.sh -c 54b-OAuth2-Distributed-Validation.yaml
+#
+# 2.) Get a token from the issuer:
+# curl -v -s -d "grant_type=password&username=john&password=password&client_id=abc&client_secret=def" localhost:7007/oauth2/token
+#
+# {"access_token":"eyJ...","token_type":"Bearer","expires_in":300}
+#
+# 3.) Call the API with the token (replace ):
+# curl -v -H "Authorization: Bearer " localhost:2000
+#
+# Hello, john!
+#
+# To trust Microsoft Entra ID instead, point jwksUris at
+# https://login.microsoftonline.com/common/discovery/keys and set expectedAud.
+
+api:
+ name: Protected API
+ port: 2000
+ flow:
+ # Fetch the issuer's public keys over HTTP and validate the JWT offline.
+ # jwtAuth resolves the JWKS at startup. If the issuer (port 7007) is not running
+ # yet, this instance still starts and fetches the keys on the first request.
+ - jwtAuth:
+ jwks:
+ jwksUris: http://localhost:7007/oauth2/certs
+ # The verified claims are available as the 'jwt' exchange property.
+ - setBody:
+ language: SpEL
+ contentType: text/plain
+ value: "Hello, ${properties['jwt']['sub']}!"
+ - return:
+ status: 200
+
+# NOTE: This tutorial is intentionally kept simple. In production, run all
+# endpoints behind TLS. Otherwise, OAuth2 transmits credentials and bearer
+# tokens in plain text. See 10-TLS-Termination.yaml for how to enable TLS.
diff --git a/distribution/tutorials/security/README.md b/distribution/tutorials/security/README.md
index aed07bea32..d33d0e7e53 100644
--- a/distribution/tutorials/security/README.md
+++ b/distribution/tutorials/security/README.md
@@ -11,14 +11,36 @@ 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
+1. [40-JWT-Requesting-Token.md](40-JWT-Requesting-Token.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.
+2. [41-JWT-Signing.yaml](41-JWT-Signing.yaml) — sign and validate JWTs with the
+ jwtSign interceptor, a lightweight alternative to a full OAuth2 token endpoint.
## 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).
+Start with [40-JWT-Requesting-Token.md](40-JWT-Requesting-Token.md), then run
+[41-JWT-Signing.yaml](41-JWT-Signing.yaml).
+
+# OAuth2 Tutorial
+
+Run a complete OAuth2 setup with Membrane acting as both the authorization
+server and the token-validating gateway. The tutorials build on each other,
+from simple to advanced:
+
+1. [50-OAuth2-Basics.yaml](50-OAuth2-Basics.yaml) — the smallest complete OAuth2 loop:
+ get a token, call a protected API, watch the gateway validate it.
+2. [51-OAuth2-Client-Credentials.yaml](51-OAuth2-Client-Credentials.yaml) — machine-to-machine
+ access with signed JWTs carrying audience and scope claims.
+3. [52-OAuth2-Password-Flow.yaml](52-OAuth2-Password-Flow.yaml) — a user logs in with
+ username and password, adding a second identity to the token.
+4. [53-OAuth2-Client-Token-Renewal.yaml](53-OAuth2-Client-Token-Renewal.yaml) — the gateway
+ fetches and renews tokens transparently for its callers.
+5. [54a-OAuth2-Distributed-Issuer.yaml](54a-OAuth2-Distributed-Issuer.yaml) +
+ [54b-OAuth2-Distributed-Validation.yaml](54b-OAuth2-Distributed-Validation.yaml) — run the
+ issuer and the validating gateway as two separate instances; the validator fetches the
+ issuer's public keys over its JWKS endpoint.
+
+Start with [50-OAuth2-Basics.yaml](50-OAuth2-Basics.yaml) and follow the
+instructions in the file.
diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md
deleted file mode 100644
index bd35d03431..0000000000
--- a/docs/RELEASE_NOTES.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# 7.3.0
-
-## New Features
-- Add configurable HTTP method validation via a `methodValidator` component, with `defaultMethodValidator`, `knownMethodValidator`, `rfc9110MethodValidator` and `uppercaseMethodValidator` policies; requests with a disallowed method are rejected with `501 Not Implemented` before reaching the flow [#3032](https://github.com/membrane/api-gateway/pull/3032)
-
-# 6.0.0
-
-Membrane Version 6 is a big step forward from Membrane 5. Big parts of the code base were refactored and improved.
-
-## New Features
-- setHeader now supports also Groovy, XPath, Jsonpath
-- New plugins `call`, `destination`
-- API key stores for JDBC and MongoDB
-
-## Improvements
-- New flow control through plugins. Not based on a stack of executed interceptors but on the definition in the 'proxies.xml' file.
-- New `log` plugin with more features and cleaner configuration. It can now dump the exchange and properties
-- ProblemDetails format is used for most of the error messages
-- Ordered fields in ProblemDetails
-- References in OpenAPI are now supported
-- In SpEL and Groovy plugins besides `headers.` and `properties.` now also the singulars `header.` will work
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index a666cc3cc8..1242598888 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -98,3 +98,5 @@ PRIO 3:
- JSONBody
- Store body as parsed JsonNode or Document
- If JSON is needed by an interceptor use already parsed JSON
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index a091e2b6d1..56128b5644 100644
--- a/pom.xml
+++ b/pom.xml
@@ -504,7 +504,7 @@
maven-surefire-plugin
- -Dfile.encoding=UTF-8
+ -Dfile.encoding=UTF-8 -Djdk.xml.maxGeneralEntitySizeLimit=0 -Djdk.xml.totalEntitySizeLimit=0
3.1.2
@@ -513,7 +513,7 @@
maven-failsafe-plugin
3.1.2
- -Dfile.encoding=UTF-8
+ -Dfile.encoding=UTF-8 -Djdk.xml.maxGeneralEntitySizeLimit=0 -Djdk.xml.totalEntitySizeLimit=0
diff --git a/war/pom.xml b/war/pom.xml
index 76d0c6b912..93bc13a173 100644
--- a/war/pom.xml
+++ b/war/pom.xml
@@ -144,7 +144,7 @@
**/IntegrationTests.java
- -Dfile.encoding=UTF-8
+ -Dfile.encoding=UTF-8 -Djdk.xml.maxGeneralEntitySizeLimit=0 -Djdk.xml.totalEntitySizeLimit=0