diff --git a/core/pom.xml b/core/pom.xml index ee3c404fe2..e481494ec2 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -414,7 +414,7 @@ maven-surefire-plugin **/UnitTests.java - -Dfile.encoding=UTF-8 + -Dfile.encoding=UTF-8 -Djdk.xml.maxGeneralEntitySizeLimit=0 -Djdk.xml.totalEntitySizeLimit=0 diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/SessionManager.java b/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/SessionManager.java index fb16b19da0..2c8fec3e4b 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/SessionManager.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/SessionManager.java @@ -13,17 +13,19 @@ limitations under the License. */ package com.predic8.membrane.core.interceptor.authentication.session; -import com.predic8.membrane.annot.*; -import com.predic8.membrane.core.config.*; -import com.predic8.membrane.core.exchange.*; -import com.predic8.membrane.core.interceptor.authentication.session.CleanupThread.*; +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.core.config.AbstractXmlElement; +import com.predic8.membrane.core.exchange.Exchange; +import com.predic8.membrane.core.interceptor.authentication.session.CleanupThread.Cleaner; import com.predic8.membrane.core.interceptor.oauth2.SessionFinder; -import com.predic8.membrane.core.proxies.*; -import com.predic8.membrane.core.router.*; -import org.apache.commons.lang3.*; -import org.jetbrains.annotations.*; +import com.predic8.membrane.core.proxies.Proxy; +import com.predic8.membrane.core.proxies.SSLableProxy; +import com.predic8.membrane.core.router.Router; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; -import javax.xml.stream.*; +import javax.xml.stream.XMLStreamReader; import java.util.*; /** @@ -113,7 +115,10 @@ public static class Session { private Map 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