Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d62c6af
Add JWT authentication tutorials and tests
predic8 Jun 27, 2026
3951855
Add OAuth2 Client Token Renewal tutorial and test
predic8 Jun 27, 2026
794e2cd
Add missing copyright headers to source files
predic8 Jun 28, 2026
77ff13f
Add token expiration and renewal handling to OAuth2 tutorial and test
predic8 Jun 28, 2026
4d2b724
Merge branch 'master' into tutorials-oauth2
predic8 Jun 28, 2026
8dd21e3
Merge branch 'master' into tutorials-oauth2
predic8 Jun 28, 2026
f45a557
Fix flow structure in OAuth2 Client Token Renewal tutorial
predic8 Jun 28, 2026
9f339bc
Merge remote-tracking branch 'origin/tutorials-oauth2' into tutorials…
predic8 Jun 28, 2026
6c81a1e
Update OAuth2APIExampleTest to use precise log matching criteria
predic8 Jun 28, 2026
7704873
Merge branch 'master' into tutorials-oauth2
christiangoerdes Jun 29, 2026
7fd0e0d
Refactor and expand JWT and OAuth2 tutorials, update tests, and impro…
predic8 Jul 1, 2026
e8f3b8d
fix(tutorials): update expected status code in JWT test to 401
predic8 Jul 1, 2026
4f8a5de
fix(tutorials): clarify endpoint configuration in OAuth2 Token Valida…
predic8 Jul 2, 2026
9319f2d
feat(oauth2): issue aud and scope claims in JWT access tokens
predic8 Jul 2, 2026
3bcaaf3
feat(oauth2): issue iss claim in JWT access tokens
predic8 Jul 2, 2026
fe4364d
feat(oauth2): make userDataProvider optional for user-less flows
predic8 Jul 2, 2026
815aff7
improve(oauth2/jwt): log rejected tokens and expose validation details
predic8 Jul 2, 2026
ad92284
feat(jwt): add expectedIss attribute to jwtAuth
predic8 Jul 2, 2026
979d0e6
fix(jwt): read scopes from RFC 9068 "scope" claim in JWTSecurityScheme
predic8 Jul 2, 2026
df02e07
feat(jwt): load JWKS lazily when URIs are unreachable at startup
predic8 Jul 2, 2026
dd7e82d
Merge branch 'master' into tutorials-oauth2
predic8 Jul 2, 2026
4eaaa92
feat(tutorials): migrate and reorganize OAuth2 and JWT tutorials with…
predic8 Jul 2, 2026
1540ce3
feat(oauth2): add test to verify refresh token validity post-cleanup …
predic8 Jul 2, 2026
1d40912
fix(tutorials): update resource access message and add missing flow c…
predic8 Jul 2, 2026
fd66d65
feat(oauth2): implement refresh token rotation and add single-use test
predic8 Jul 2, 2026
51a6746
minor improvements
christiangoerdes Jul 3, 2026
7d43d94
feat(jwt): add configurable `scopesClaim` for JWTSecurityScheme
predic8 Jul 4, 2026
db93ba2
fix(tutorials): update JWT claim assertions in RequestingTokenTutoria…
predic8 Jul 4, 2026
7cb72d9
Merge branch 'master' into tutorials-oauth2
predic8 Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<test>**/UnitTests.java</test>
<argLine>-Dfile.encoding=UTF-8</argLine>
<argLine>-Dfile.encoding=UTF-8 -Djdk.xml.maxGeneralEntitySizeLimit=0 -Djdk.xml.totalEntitySizeLimit=0</argLine>
</configuration>
</plugin>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

/**
Expand Down Expand Up @@ -113,7 +115,10 @@ public static class Session {

private Map<String, String> 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(),"");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@

/**
* @description
* JSON Web Key Set, configured <b>either</b> by an explicit list of JWK <b>or</b> by a list of JWK URIs that will be refreshed periodically.
* <p>JSON Web Key Set, configured <b>either</b> by an explicit list of JWK <b>or</b> by a list of JWK URIs.</p>
* <p>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.</p>
* <p>If an authorizationService with a jwksRefreshInterval is configured, the key set is additionally
* refreshed periodically.</p>
*/
@MCElement(name="jwks")
public class Jwks {
Expand All @@ -66,6 +72,7 @@ public class Jwks {
List<String> jwksUris = emptyList();
AuthorizationService authorizationService;
private Router router;

private final Runnable refreshJwksTask = () -> {
try {
List<Jwk> loaded = loadJwks(true);
Expand All @@ -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())
Comment thread
rrayst marked this conversation as resolved.
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<Jwk> getJwks() {
reloadJwksIfNeeded();
return jwks;
}

Expand All @@ -121,6 +154,7 @@ public void setJwksUris(String jwksUris) {
}

public Optional<RsaJsonWebKey> getKeyByKid(String kid) {
reloadJwksIfNeeded();
return Optional.ofNullable(keysByKid.get(kid));
}

Expand Down Expand Up @@ -151,6 +185,7 @@ private Map<String, RsaJsonWebKey> buildKeyMap(List<Jwk> jwks) {
}

private List<Jwk> 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)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
* <pre><code>
* jwtAuth:
* expectedAud: my-audience
* expectedIss: https://auth.example.com
* expectedTid: 67c859d3-0cd4-4a99-86db-088bed1a9601
* jwks: {}
* </code></pre>
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand All @@ -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<String, Object> jwtClaims = createValidator(key).processToClaims(jwt).getClaimsMap();

exc.getProperties().put("jwt",jwtClaims);

new JWTSecurityScheme(jwtClaims).add(exc);
new JWTSecurityScheme(jwtClaims, scopesClaim).add(exc);

return CONTINUE;
}
Expand All @@ -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();
}

Expand Down Expand Up @@ -234,6 +244,39 @@ public void setExpectedTid(String expectedTid) {
this.expectedTid = expectedTid;
}

public String getExpectedIss() {
return expectedIss;
}

/**
* @description
* <p>Expected issuer ('iss') value of the token. If set, tokens without an 'iss' claim or with a
* different issuer are rejected.</p>
* @default not set
* @example https://auth.example.com
*/
@MCAttribute
public void setExpectedIss(String expectedIss) {
this.expectedIss = expectedIss;
}

public String getScopesClaim() {
return scopesClaim;
}

/**
* @description
* <p>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.</p>
* @default scp
* @example scope
*/
@MCAttribute
public void setScopesClaim(String scopesClaim) {
this.scopesClaim = scopesClaim;
}

@Override
public String getShortDescription() {
return "Checks for a valid JWT.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(){
}
Expand Down Expand Up @@ -64,7 +68,6 @@ public String getCallbackUrl() {
return callbackUrl;
}

@Required
@MCAttribute
public void setCallbackUrl(String callbackUrl) {
this.callbackUrl = callbackUrl;
Expand All @@ -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<String> 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<String> getScopeList() {
return splitList(scopes);
}

private static List<String> splitList(String value) {
if (value == null || value.isBlank())
return List.of();
return List.of(value.trim().split(" +"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ public void init() {
throw new ConfigurationException("Could not create token generators.",e);
}

tokenGenerator.setIssuer(issuer);
refreshTokenGenerator.setIssuer(issuer);

addSupportedAuthorizationGrants();

try {
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ public void removeSessionForToken(String token) {
}
}

public void removeSessionForRefreshToken(String refreshToken) {
synchronized (refreshTokensToSession) {
refreshTokensToSession.remove(refreshToken);
}
}

public void cleanupSessions(Set<SessionManager.Session> sessionsToRemove) {

cleanupMap(sessionsToRemove, authCodesToSession);
Expand Down
Loading
Loading