diff --git a/CHANGELOG.md b/CHANGELOG.md
index 847a930..b8b7387 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
## Unreleased
+## 4.1.0 (2026-07-30)
+
+- Support `aud` claim in JWT when it's specified as an array rather than a string.
+- Allow JWT validators to configure a Set of accepted audiences and issuers, not just one.
+- OAuthJwtFilter configuration now accepts whitespace-separated values for `audience` and `issuer`.
+- Minimum Java version requirement changed to 15.
+
## 4.0.0 (2023-11-27)
- Added support for EdDSA signatures.
diff --git a/pom.xml b/pom.xml
index ffdd5fa..b355cbf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
io.curity
oauth-filter
- 4.0.0
+ 4.1.0
OAuth API Filter
A Servlet Filter that authenticates and authorizes requests using OAuth access tokens of various kinds.
https://github.com/curityio/oauth-filter-for-java
@@ -73,10 +73,11 @@
org.apache.maven.plugins
maven-compiler-plugin
- 3.1
+ 3.8.1
- 11
- 11
+
+ 15
+ 15
true
false
@@ -269,7 +270,7 @@
- 11
+ 15
diff --git a/readme.md b/readme.md
index cc37db4..e340908 100644
--- a/readme.md
+++ b/readme.md
@@ -62,24 +62,26 @@ depending on the format of the token your OAuth server is using.
### Init-params for the `OAuthJwtFilter`
-Configuration Setting Name | Description
----------------------------|----------------
-oauthHost | Hostname of the OAuth server.
-oauthPort | Port of the OAuth server.
-jsonWebKeysPath | Path to the JWKS endpoint on the OAuth server.
-scope | A space separated list of scopes required to access the API.
-minKidReloadTimeInSeconds | Minimum time to reload the webKeys cache used by the Filter.
+| Configuration Setting Name | Description |
+|----------------------------|----------------------------------------------------------------------------------------------|
+| oauthHost | Hostname of the OAuth server. |
+| oauthPort | Port of the OAuth server. |
+| jsonWebKeysPath | Path to the JWKS endpoint on the OAuth server. |
+| scope | A space separated list of scopes required to access the API. |
+| minKidReloadTimeInSeconds | Minimum time to reload the webKeys cache used by the Filter. |
+| issuer | A space separated list of issuers to accept. If not specified, all issuers are accepted. |
+| audience | A space separated list of audiences to accept. If not specified, all audiences are accepted. |
### Init-params for the `OAuthOpaqueFilter`
-Configuration Setting Name | Description
----------------------------|----------------
-oauthHost | Hostname of the OAuth server.
-oauthPort | Port of the OAuth server.
-introspectionPath | Path to the introspection endpoint on the OAuth server.
-scope | A space separated list of scopes required to access the API.
-clientId | Your application's client id to use for introspection.
-clientSecret | Your application's client secret.
+| Configuration Setting Name | Description |
+|----------------------------|--------------------------------------------------------------|
+| oauthHost | Hostname of the OAuth server. |
+| oauthPort | Port of the OAuth server. |
+| introspectionPath | Path to the introspection endpoint on the OAuth server. |
+| scope | A space separated list of scopes required to access the API. |
+| clientId | Your application's client id to use for introspection. |
+| clientSecret | Your application's client secret. |
## Providing external services (`io.curity.oauth.HttpClientProvider` and `javax.json.spi.JsonProvider`)
diff --git a/src/main/java/io/curity/oauth/AbstractJwtValidator.java b/src/main/java/io/curity/oauth/AbstractJwtValidator.java
index 959f7bb..80a8684 100644
--- a/src/main/java/io/curity/oauth/AbstractJwtValidator.java
+++ b/src/main/java/io/curity/oauth/AbstractJwtValidator.java
@@ -1,12 +1,12 @@
/*
* Copyright (C) 2016 Curity AB.
- *
+ *
* 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.
@@ -30,6 +30,7 @@
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -42,16 +43,31 @@ abstract class AbstractJwtValidator implements JwtValidator
private final Map _decodedJwtBodyByEncodedBody = new HashMap<>(1);
private final Map _decodedJwtHeaderByEncodedHeader = new HashMap<>(1);
private final JsonReaderFactory _jsonReaderFactory;
- private final String _audience;
- private final String _issuer;
+ private final Set _audiences;
+ private final Set _issuers;
- AbstractJwtValidator(String issuer, String audience, JsonReaderFactory jsonReaderFactory)
+ protected AbstractJwtValidator(Set issuers, Set audiences, JsonReaderFactory jsonReaderFactory)
{
- _issuer = issuer;
- _audience = audience;
+ _issuers = Set.copyOf(issuers);
+ _audiences = Set.copyOf(audiences);
_jsonReaderFactory = jsonReaderFactory;
}
+ protected AbstractJwtValidator(String issuer, String audience, JsonReaderFactory jsonReaderFactory)
+ {
+ this(Set.of(issuer), Set.of(audience), jsonReaderFactory);
+ }
+
+ protected AbstractJwtValidator(String issuer, Set audiences, JsonReaderFactory jsonReaderFactory)
+ {
+ this(Set.of(issuer), audiences, jsonReaderFactory);
+ }
+
+ protected AbstractJwtValidator(Set issuers, String audience, JsonReaderFactory jsonReaderFactory)
+ {
+ this(issuers, Set.of(audience), jsonReaderFactory);
+ }
+
public final JsonData validate(String jwt) throws TokenValidationException
{
String[] jwtParts = jwt.split("\\.");
@@ -73,20 +89,20 @@ public final JsonData validate(String jwt) throws TokenValidationException
long exp = JsonUtils.getLong(jwtBody, "exp");
long iat = JsonUtils.getLong(jwtBody, "iat");
- String aud = JsonUtils.getString(jwtBody, "aud");
+ Set aud = JsonUtils.getStringOrStrings(jwtBody, "aud");
String iss = JsonUtils.getString(jwtBody, "iss");
- assert aud != null && aud.length() > 0 : "aud claim is not present in JWT";
- assert iss != null && iss.length() > 0 : "iss claim is not present in JWT";
+ assert !aud.isEmpty() : "aud claim is not present or is invalid in JWT";
+ assert iss != null && !iss.isEmpty() : "iss claim is not present in JWT";
- if (!aud.equals(_audience))
+ if (!_audiences.isEmpty() && aud.stream().noneMatch(_audiences::contains))
{
- throw new InvalidAudienceException(_audience, aud);
+ throw new InvalidAudienceException(String.join(", ", _audiences), String.join(", ", aud));
}
- if (!iss.equals(_issuer))
+ if (!_issuers.isEmpty() && !_issuers.contains(iss))
{
- throw new InvalidIssuerException(_issuer, iss);
+ throw new InvalidIssuerException(String.join(", ", _issuers), iss);
}
Instant now = Instant.now();
@@ -230,7 +246,7 @@ private JwtHeader decodeJwtHeader(String header)
});
}
- class JwtHeader
+ static final class JwtHeader
{
private final JsonObject _jsonObject;
diff --git a/src/main/java/io/curity/oauth/FilterHelper.java b/src/main/java/io/curity/oauth/FilterHelper.java
index 0beb82a..9c7ed4c 100644
--- a/src/main/java/io/curity/oauth/FilterHelper.java
+++ b/src/main/java/io/curity/oauth/FilterHelper.java
@@ -1,12 +1,12 @@
/*
* Copyright (C) 2016 Curity AB.
- *
+ *
* 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.
@@ -18,8 +18,10 @@
import jakarta.servlet.FilterConfig;
import jakarta.servlet.UnavailableException;
+
import java.util.Enumeration;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
@@ -67,15 +69,24 @@ static T getInitParamValue(String name, Map initParams,
}
static Optional getOptionalInitParamValue(String name, Map initParams,
- Function converter) throws UnavailableException
+ Function converter)
+ throws UnavailableException
{
Optional value = getSingleValue(name, initParams);
return value.flatMap(s -> Optional.ofNullable(converter.apply(s)));
}
- private static Optional getSingleValue(String name, Map initParams) throws
- UnavailableException
+ static List getWhitespaceSeparatedInitParamValues(String name, Map initParams)
+ throws UnavailableException
+ {
+ Optional value = getSingleValue(name, initParams);
+
+ return value.map(v -> List.of(v.split("\\s+"))).orElseGet(List::of);
+ }
+
+ private static Optional getSingleValue(String name, Map initParams)
+ throws UnavailableException
{
return Optional.ofNullable(initParams.get(name)).map(Object::toString);
}
diff --git a/src/main/java/io/curity/oauth/JsonUtils.java b/src/main/java/io/curity/oauth/JsonUtils.java
index 800bce3..751fc2e 100644
--- a/src/main/java/io/curity/oauth/JsonUtils.java
+++ b/src/main/java/io/curity/oauth/JsonUtils.java
@@ -16,6 +16,7 @@
package io.curity.oauth;
+import javax.json.JsonArray;
import javax.json.JsonNumber;
import javax.json.JsonObject;
import javax.json.JsonReaderFactory;
@@ -27,6 +28,7 @@
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Collectors;
final class JsonUtils
{
@@ -49,14 +51,38 @@ static Set getScopes(JsonObject jsonObject)
return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(presentedScopes)));
}
+ /**
+ * Get the string value of a JSON property.
+ *
+ * @param jsonObject The JSON object containing the property.
+ * @param name The name of the property.
+ * @return The string value of the property, or null if not present or not a string.
+ */
static String getString(JsonObject jsonObject, String name)
{
return Optional.ofNullable(jsonObject.get(name))
.filter(it -> it.getValueType() == JsonValue.ValueType.STRING)
- .map(it -> ((JsonString) it).getString())
+ .map(JsonUtils::stringValue)
.orElse(null);
}
+ /**
+ * Get the string values of a JSON property.
+ *
+ * @param jsonObject The JSON object containing the property.
+ * @param name The name of the property.
+ * @return The string values of the property, or an empty set if not present or not a string or array.
+ * If the property is an array, it must only contain strings, otherwise this method will throw a
+ * {@link ClassCastException}.
+ */
+ static Set getStringOrStrings(JsonObject jsonObject, String name)
+ {
+ return Optional.ofNullable(jsonObject.get(name))
+ .filter(it -> it.getValueType() == JsonValue.ValueType.STRING || it.getValueType() == JsonValue.ValueType.ARRAY)
+ .map(JsonUtils::stringOrStringArrayValue)
+ .orElse(Collections.emptySet());
+ }
+
static long getLong(JsonObject jsonObject, String name)
{
return Optional.ofNullable(jsonObject.get(name))
@@ -64,4 +90,26 @@ static long getLong(JsonObject jsonObject, String name)
.map(it -> ((JsonNumber) it).longValue())
.orElse(Long.MIN_VALUE);
}
+
+ private static String stringValue(JsonValue value)
+ {
+ return ((JsonString) value).getString();
+ }
+
+ private static Set stringOrStringArrayValue(JsonValue value)
+ {
+ if (value.getValueType() == JsonValue.ValueType.STRING)
+ {
+ return Set.of(stringValue(value));
+ }
+ if (value.getValueType() == JsonValue.ValueType.ARRAY)
+ {
+ return ((JsonArray) value).stream()
+ .map(JsonUtils::stringValue)
+ .collect(Collectors.toSet());
+ }
+
+ // be extremely lenient and ignore the value
+ return Set.of();
+ }
}
diff --git a/src/main/java/io/curity/oauth/JwtValidatorWithCert.java b/src/main/java/io/curity/oauth/JwtValidatorWithCert.java
index 35db2b8..7a3a4e1 100644
--- a/src/main/java/io/curity/oauth/JwtValidatorWithCert.java
+++ b/src/main/java/io/curity/oauth/JwtValidatorWithCert.java
@@ -20,27 +20,59 @@
import java.security.PublicKey;
import java.util.Map;
import java.util.Optional;
-import java.util.logging.Logger;
+import java.util.Set;
final class JwtValidatorWithCert extends AbstractJwtValidator
{
- private static final Logger _logger = Logger.getLogger(JwtValidatorWithCert.class.getName());
private final Map _keys;
+ JwtValidatorWithCert(Set issuers, Set audiences, Map publicKeys)
+ {
+ this(issuers, audiences, publicKeys, JsonUtils.createDefaultReaderFactory());
+ }
+
+ JwtValidatorWithCert(String issuer, Set audiences, Map publicKeys)
+ {
+ this(Set.of(issuer), audiences, publicKeys);
+ }
+
+ JwtValidatorWithCert(Set issuers, String audience, Map publicKeys)
+ {
+ this(issuers, Set.of(audience), publicKeys);
+ }
+
JwtValidatorWithCert(String issuer, String audience, Map publicKeys)
{
- this(issuer, audience, publicKeys, JsonUtils.createDefaultReaderFactory());
+ this(Set.of(issuer), audience, publicKeys);
}
- JwtValidatorWithCert(String issuer, String audience, Map publicKeys,
+ JwtValidatorWithCert(Set issuers, Set audiences, Map publicKeys,
JsonReaderFactory jsonReaderFactory)
{
- super(issuer, audience, jsonReaderFactory);
-
+ super(issuers, audiences, jsonReaderFactory);
+
_keys = publicKeys;
}
+ JwtValidatorWithCert(String issuer, Set audiences, Map publicKeys,
+ JsonReaderFactory jsonReaderFactory)
+ {
+ this(Set.of(issuer), audiences, publicKeys, jsonReaderFactory);
+ }
+
+ JwtValidatorWithCert(Set issuers, String audience, Map publicKeys,
+ JsonReaderFactory jsonReaderFactory)
+ {
+ this(issuers, Set.of(audience), publicKeys, jsonReaderFactory);
+ }
+
+ JwtValidatorWithCert(String issuer, String audience, Map publicKeys,
+ JsonReaderFactory jsonReaderFactory)
+ {
+ this(Set.of(issuer), audience, publicKeys, jsonReaderFactory);
+ }
+
@Override
protected Optional getPublicKey(JwtHeader jwtHeader)
{
diff --git a/src/main/java/io/curity/oauth/JwtValidatorWithJwk.java b/src/main/java/io/curity/oauth/JwtValidatorWithJwk.java
index 8260a6a..ca0fbc3 100644
--- a/src/main/java/io/curity/oauth/JwtValidatorWithJwk.java
+++ b/src/main/java/io/curity/oauth/JwtValidatorWithJwk.java
@@ -22,6 +22,7 @@
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.util.Optional;
+import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -31,14 +32,32 @@ final class JwtValidatorWithJwk extends AbstractJwtValidator
private final JwkManager _jwkManager;
- JwtValidatorWithJwk(long minKidReloadTime, WebKeysClient webKeysClient, String audience, String issuer,
+ JwtValidatorWithJwk(long minKidReloadTime, WebKeysClient webKeysClient, Set audiences, Set issuers,
JsonReaderFactory jsonReaderFactory)
{
- super(issuer, audience, jsonReaderFactory);
-
+ super(issuers, audiences, jsonReaderFactory);
+
_jwkManager = new JwkManager(minKidReloadTime, webKeysClient, jsonReaderFactory);
}
+ JwtValidatorWithJwk(long minKidReloadTime, WebKeysClient webKeysClient, Set audiences, String issuer,
+ JsonReaderFactory jsonReaderFactory)
+ {
+ this(minKidReloadTime, webKeysClient, audiences, Set.of(issuer), jsonReaderFactory);
+ }
+
+ JwtValidatorWithJwk(long minKidReloadTime, WebKeysClient webKeysClient, String audience, String issuer,
+ JsonReaderFactory jsonReaderFactory)
+ {
+ this(minKidReloadTime, webKeysClient, Set.of(audience), Set.of(issuer), jsonReaderFactory);
+ }
+
+ JwtValidatorWithJwk(long minKidReloadTime, WebKeysClient webKeysClient, String audience, Set issuers,
+ JsonReaderFactory jsonReaderFactory)
+ {
+ this(minKidReloadTime, webKeysClient, Set.of(audience), issuers, jsonReaderFactory);
+ }
+
@Override
protected Optional getPublicKey(JwtHeader jwtHeader)
{
diff --git a/src/main/java/io/curity/oauth/OAuthFilter.java b/src/main/java/io/curity/oauth/OAuthFilter.java
index c68b21f..3e786c1 100644
--- a/src/main/java/io/curity/oauth/OAuthFilter.java
+++ b/src/main/java/io/curity/oauth/OAuthFilter.java
@@ -1,12 +1,12 @@
/*
* Copyright (C) 2016 Curity AB.
- *
+ *
* 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.
@@ -25,8 +25,8 @@
import jakarta.servlet.UnavailableException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+
import java.io.IOException;
-import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -35,15 +35,15 @@
public abstract class OAuthFilter implements Filter
{
- private static final String[] NO_SCOPES = {};
private static final Logger _logger = Logger.getLogger(OAuthFilter.class.getName());
+
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
private static final String AUTHORIZATION = "Authorization";
public static final String PRINCIPAL_ATTRIBUTE_NAME = "principal";
private Map _filterConfig; // Protected, so subclasses don't have to repeat the conversion to this
private String _oauthHost = null;
- private String[] _scopes = null;
+ private List _scopes = List.of();
private interface InitParams
{
@@ -58,25 +58,25 @@ public void init(FilterConfig filterConfig) throws ServletException
_oauthHost = FilterHelper.getInitParamValue(InitParams.OAUTH_HOST, _filterConfig);
- _scopes = FilterHelper.getOptionalInitParamValue(InitParams.SCOPE, _filterConfig, it -> it.split("\\s+"))
- .orElse(NO_SCOPES);
+ _scopes = FilterHelper.getWhitespaceSeparatedInitParamValues(InitParams.SCOPE, _filterConfig);
}
/**
* The doFilter is the primary filter method of a Servlet filter. It is implemented as a final method
* and will call the configured filters authenticate and authorize methods.
* Authorize is optional to implement as this filter implements a default scope check method.
- * @param servletRequest The default servlet request
+ *
+ * @param servletRequest The default servlet request
* @param servletResponse The default servlet response
- * @param filterChain A filter chain to continue with after this filter is done
- * @throws IOException when response fails to send an error
+ * @param filterChain A filter chain to continue with after this filter is done
+ * @throws IOException when response fails to send an error
* @throws ServletException when authentication fails for some exceptional reason
*/
@Override
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException
{
- HttpServletResponse response = (HttpServletResponse)servletResponse;
+ HttpServletResponse response = (HttpServletResponse) servletResponse;
Optional token = extractAccessTokenFromHeader(servletRequest);
String oauthHost = getOAuthServerRealm();
@@ -111,7 +111,7 @@ public final void doFilter(ServletRequest servletRequest, ServletResponse servle
if (filterChain != null)
{
filterChain.doFilter(
- new AuthenticatedUserRequestWrapper((HttpServletRequest)servletRequest, authenticatedUser),
+ new AuthenticatedUserRequestWrapper((HttpServletRequest) servletRequest, authenticatedUser),
servletResponse);
}
}
@@ -163,6 +163,7 @@ protected String getOAuthServerRealm() throws UnavailableException
/**
* This is the authenticate method of the filter, it will take the token as string input and
* must perform the appropriate operation to validate the token.
+ *
* @param token - The token extracted from the Authorization header and stripped of the Bearer
* @return An AuthenticatedUser if the token was valid, or null if not.
* @throws ServletException when authentication fails for some exceptional reason
@@ -184,6 +185,7 @@ protected Optional authenticate(String token) throws ServletE
return Optional.ofNullable(result);
}
+
/**
* Authorizes the current request by checking that all configured scopes are included in the one presented in the
* request.
@@ -196,10 +198,8 @@ protected Optional authenticate(String token) throws ServletE
*/
protected boolean isAuthorized(AuthenticatedUser authenticatedUser)
{
- List requiredScopes = Arrays.asList(_scopes);
-
// No scopes required for authorization
- return requiredScopes.isEmpty() || authenticatedUser.getScopes().containsAll(requiredScopes);
+ return _scopes.isEmpty() || authenticatedUser.getScopes().containsAll(_scopes);
}
@Override
@@ -222,12 +222,13 @@ public void destroy()
/**
* Extracts the token from the Authorization header, removing the Bearer prefix
+ *
* @param request The incoming request
* @return the token or null if not present
*/
private Optional extractAccessTokenFromHeader(ServletRequest request)
{
- HttpServletRequest httpRequest = (HttpServletRequest)request;
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
String authorizationHeader = httpRequest.getHeader(AUTHORIZATION);
String result = null;
@@ -235,7 +236,7 @@ private Optional extractAccessTokenFromHeader(ServletRequest request)
{
String[] tokenSplit = authorizationHeader.split("[Bb][Ee][Aa][Rr][Ee][Rr]\\s+");
- if(tokenSplit.length != 2)
+ if (tokenSplit.length != 2)
{
_logger.fine("Incoming token in Authorization header is not a Bearer token");
}
diff --git a/src/main/java/io/curity/oauth/OAuthJwtFilter.java b/src/main/java/io/curity/oauth/OAuthJwtFilter.java
index b574cf7..f7cc0f3 100644
--- a/src/main/java/io/curity/oauth/OAuthJwtFilter.java
+++ b/src/main/java/io/curity/oauth/OAuthJwtFilter.java
@@ -1,12 +1,12 @@
/*
* Copyright (C) 2016 Curity AB.
- *
+ *
* 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.
@@ -16,12 +16,14 @@
package io.curity.oauth;
-import javax.json.JsonReaderFactory;
-import javax.json.spi.JsonProvider;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.UnavailableException;
+
+import javax.json.JsonReaderFactory;
+import javax.json.spi.JsonProvider;
import java.util.Map;
+import java.util.Set;
import java.util.logging.Logger;
public class OAuthJwtFilter extends OAuthFilter
@@ -53,7 +55,7 @@ public void init(FilterConfig filterConfig) throws ServletException
if (_jwtValidator == null)
{
_jwtValidator = createTokenValidator(getFilterConfiguration());
-
+
_logger.info(() -> String.format("%s successfully initialized", OAuthFilter.class.getSimpleName()));
}
else
@@ -71,10 +73,10 @@ protected TokenValidator createTokenValidator(Map filterConfig) throw
// the ReaderFactory using the filter's config.
JsonReaderFactory jsonReaderFactory = JsonProvider.provider().createReaderFactory(filterConfig);
WebKeysClient webKeysClient = HttpClientProvider.provider().createWebKeysClient(filterConfig);
- String audience = FilterHelper.getInitParamValue(InitParams.AUDIENCE, filterConfig);
- String issuer = FilterHelper.getInitParamValue(InitParams.ISSUER, filterConfig);
+ Set audiences = Set.copyOf(FilterHelper.getWhitespaceSeparatedInitParamValues(InitParams.AUDIENCE, filterConfig));
+ Set issuers = Set.copyOf(FilterHelper.getWhitespaceSeparatedInitParamValues(InitParams.ISSUER, filterConfig));
- return _jwtValidator = new JwtValidatorWithJwk(_minKidReloadTimeInSeconds, webKeysClient, audience, issuer,
+ return _jwtValidator = new JwtValidatorWithJwk(_minKidReloadTimeInSeconds, webKeysClient, audiences, issuers,
jsonReaderFactory);
}
diff --git a/src/main/java/io/curity/oauth/ValidationExceptions.java b/src/main/java/io/curity/oauth/ValidationExceptions.java
index a406861..15ebbd2 100644
--- a/src/main/java/io/curity/oauth/ValidationExceptions.java
+++ b/src/main/java/io/curity/oauth/ValidationExceptions.java
@@ -43,7 +43,7 @@ class MissingAlgorithmException extends TokenValidationException
class InvalidAudienceException extends TokenValidationException
{
- private static final String _formattedMessage = "Audience %s does not match expected one %s";
+ private static final String _formattedMessage = "Audience(s) '%s' does not match expected '%s'";
InvalidAudienceException(String expectedAudience, String actualAudience)
{
@@ -103,7 +103,7 @@ class UnknownAlgorithmException extends InvalidTokenFormatException
class InvalidIssuerException extends TokenValidationException
{
- private static final String _formattedMessage = "Issuer %s does not match expected one %s";
+ private static final String _formattedMessage = "Issuer '%s' does not match expected one of '%s'";
InvalidIssuerException(String expectedIssuer, String actualIssuer)
{
diff --git a/src/test/java/io/curity/oauth/FilterHelperTest.java b/src/test/java/io/curity/oauth/FilterHelperTest.java
new file mode 100644
index 0000000..8e04626
--- /dev/null
+++ b/src/test/java/io/curity/oauth/FilterHelperTest.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2016 Curity AB.
+ *
+ * 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 io.curity.oauth;
+
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.UnavailableException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.when;
+
+public class FilterHelperTest
+{
+ @Mock
+ private FilterConfig mockFilterConfig;
+
+ @Before
+ public void setUp()
+ {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ public void canProvideSimpleStrings() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("tokenEndpoint", "https://example.com/token");
+
+ String result = FilterHelper.getInitParamValue("tokenEndpoint", params);
+
+ assertEquals("https://example.com/token", result);
+ }
+
+ @Test
+ public void canProvideSimpleStringsWithConverter() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("maxRetries", "5");
+
+ Integer result = FilterHelper.getInitParamValue("maxRetries", params, Integer::parseInt);
+
+ assertEquals(Integer.valueOf(5), result);
+ }
+
+ @Test
+ public void canProvideWhiteSeparatedStringLists() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("scope", "read write delete");
+
+ List result = FilterHelper.getWhitespaceSeparatedInitParamValues("scope", params);
+
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertEquals("read", result.get(0));
+ assertEquals("write", result.get(1));
+ assertEquals("delete", result.get(2));
+ }
+
+ @Test
+ public void canProvideWhiteSeparatedStringListsWithVariousWhitespace() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("scope", "read write\tdelete\nupdate");
+
+ List result = FilterHelper.getWhitespaceSeparatedInitParamValues("scope", params);
+
+ assertNotNull(result);
+ assertEquals(4, result.size());
+ assertEquals("read", result.get(0));
+ assertEquals("write", result.get(1));
+ assertEquals("delete", result.get(2));
+ assertEquals("update", result.get(3));
+ }
+
+ @Test
+ public void canProvideSingleValueInWhiteSeparatedStringList() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("scope", "read");
+
+ List result = FilterHelper.getWhitespaceSeparatedInitParamValues("scope", params);
+
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals("read", result.get(0));
+ }
+
+ @Test(expected = UnavailableException.class)
+ public void failsWhenParameterIsMissing() throws UnavailableException
+ {
+ Map params = Collections.emptyMap();
+
+ FilterHelper.getInitParamValue("tokenEndpoint", params);
+ }
+
+ @Test
+ public void failsWhenParameterIsMissingWithCorrectMessage() throws UnavailableException
+ {
+ Map params = Collections.emptyMap();
+
+ try
+ {
+ FilterHelper.getInitParamValue("tokenEndpoint", params);
+ fail("Should have thrown UnavailableException");
+ }
+ catch (UnavailableException e)
+ {
+ assertTrue(e.getMessage().contains("missing required initParam [tokenEndpoint]"));
+ assertTrue(e.getMessage().contains("OAuthFilter"));
+ }
+ }
+
+ @Test
+ public void doesNotFailWhenOptionalParameterIsMissing() throws UnavailableException
+ {
+ Map params = Collections.emptyMap();
+
+ Optional result = FilterHelper.getOptionalInitParamValue("audience", params,
+ v -> v);
+
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ public void doesNotFailWhenOptionalParameterIsPresentWithConverter() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("connectionTimeout", "30000");
+
+ Optional result = FilterHelper.getOptionalInitParamValue("connectionTimeout", params,
+ Long::parseLong);
+
+ assertTrue(result.isPresent());
+ assertEquals(Long.valueOf(30000), result.get());
+ }
+
+ @Test
+ public void doesNotFailWhenOptionalParameterIsNull() throws UnavailableException
+ {
+ Map params = Collections.singletonMap("someParam", null);
+
+ Optional result = FilterHelper.getOptionalInitParamValue("someParam", params,
+ v -> v);
+
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ public void doesNotFailWhenNoParametersProvidedForWhiteSpaceSeparatedStringLists()
+ throws UnavailableException
+ {
+ Map params = Collections.emptyMap();
+
+ List result = FilterHelper.getWhitespaceSeparatedInitParamValues("scope", params);
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void initParamsMapFromWithNoParameters()
+ {
+ when(mockFilterConfig.getInitParameterNames())
+ .thenReturn(Collections.emptyEnumeration());
+
+ Map result = FilterHelper.initParamsMapFrom(mockFilterConfig);
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void initParamsMapFromWithSingleParameter()
+ {
+ when(mockFilterConfig.getInitParameterNames())
+ .thenReturn(Collections.enumeration(Collections.singletonList("tokenEndpoint")));
+ when(mockFilterConfig.getInitParameter("tokenEndpoint"))
+ .thenReturn("https://example.com/token");
+
+ Map result = FilterHelper.initParamsMapFrom(mockFilterConfig);
+
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals("https://example.com/token", result.get("tokenEndpoint"));
+ }
+
+ @Test
+ public void initParamsMapFromWithMultipleParameters()
+ {
+ List paramNames = Arrays.asList("tokenEndpoint", "scope", "audience");
+ when(mockFilterConfig.getInitParameterNames())
+ .thenReturn(Collections.enumeration(paramNames));
+ when(mockFilterConfig.getInitParameter("tokenEndpoint"))
+ .thenReturn("https://example.com/token");
+ when(mockFilterConfig.getInitParameter("scope"))
+ .thenReturn("read write");
+ when(mockFilterConfig.getInitParameter("audience"))
+ .thenReturn("api");
+
+ Map result = FilterHelper.initParamsMapFrom(mockFilterConfig);
+
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertEquals("https://example.com/token", result.get("tokenEndpoint"));
+ assertEquals("read write", result.get("scope"));
+ assertEquals("api", result.get("audience"));
+ }
+}
diff --git a/src/test/java/io/curity/oauth/JwtAudienceClaimTest.java b/src/test/java/io/curity/oauth/JwtAudienceClaimTest.java
new file mode 100644
index 0000000..264d524
--- /dev/null
+++ b/src/test/java/io/curity/oauth/JwtAudienceClaimTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2016 Curity AB.
+ *
+ * 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 io.curity.oauth;
+
+import org.junit.Test;
+
+import java.security.PublicKey;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import static junit.framework.TestCase.assertNotNull;
+import static org.junit.Assert.assertThrows;
+
+public class JwtAudienceClaimTest
+{
+ private static final String ISSUER = "test:issuer";
+
+ @Test
+ public void testValidatorAcceptsOneAudienceAndTokenHasValidAudienceString() throws Exception
+ {
+ assertTokenValidation("some:audience",
+ keys -> new JwtValidatorWithCert(ISSUER, "some:audience", keys), true);
+ }
+
+ @Test
+ public void testValidatorAcceptsOneAudienceAndTokenHasValidAudienceArray() throws Exception
+ {
+ assertTokenValidation(List.of("some:audience", "other:audience"),
+ keys -> new JwtValidatorWithCert(ISSUER, "some:audience", keys), true);
+ }
+
+ @Test
+ public void testValidatorAcceptsOneAudienceAndTokenHasInvalidAudienceString() throws Exception
+ {
+ assertTokenValidation("some:audience",
+ keys -> new JwtValidatorWithCert(ISSUER, "other:audience", keys), false);
+ }
+
+ @Test
+ public void testValidatorAcceptsOneAudienceAndTokenHasOnlyInvalidAudiencesInArray() throws Exception
+ {
+ assertTokenValidation(List.of("invalid:audience", "other:invalid:audience"),
+ keys -> new JwtValidatorWithCert(ISSUER, "valid:audience", keys), false);
+ }
+
+ @Test
+ public void testValidatorAcceptsManyAudiencesAndTokenHasValidAudienceString() throws Exception
+ {
+ assertTokenValidation("valid:audience",
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of("valid:audience", "something:else"), keys), true);
+ }
+
+ @Test
+ public void testValidatorAcceptsManyAudiencesAndTokenHasValidAudiencesArray() throws Exception
+ {
+ assertTokenValidation(List.of("x", "y", "a"),
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of("a", "b"), keys), true);
+
+ assertTokenValidation(List.of("x", "b", "y"),
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of("a", "b"), keys), true);
+
+ assertTokenValidation(List.of("a", "b"),
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of("a", "b"), keys), true);
+ }
+
+ @Test
+ public void testValidatorAcceptsManyAudiencesAndTokenHasInvalidAudienceString() throws Exception
+ {
+ assertTokenValidation("invalid:audience",
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of("a", "b"), keys), false);
+ }
+
+ @Test
+ public void testValidatorAcceptsManyAudiencesAndTokenHasOnlyInvalidAudiencesArray() throws Exception
+ {
+ assertTokenValidation(List.of("invalid:audience", "other:audience"),
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of("a", "b"), keys), false);
+ }
+
+ @Test
+ public void testValidatorAcceptsNoAudiencesAndTokenHasAnyAudience() throws Exception
+ {
+ assertTokenValidation("any:audience",
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of(), keys), true);
+
+ assertTokenValidation(List.of("any:audience", "another:audience"),
+ keys -> new JwtValidatorWithCert(ISSUER, Set.of(), keys), true);
+ }
+
+ @Test
+ public void testValidatorAcceptsNoIssuersAndTokenHasAnyIssuer() throws Exception
+ {
+ assertTokenValidation("some:audience",
+ keys -> new JwtValidatorWithCert(Set.of(), Set.of("some:audience"), keys), true);
+
+ assertTokenValidation(List.of("some:audience", "other:audience"),
+ keys -> new JwtValidatorWithCert(Set.of(), Set.of("some:audience"), keys), true);
+ }
+
+ private static void assertTokenValidation(String tokenAudience,
+ Function