This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit d9b1453d2895cd2fe75a1c60093b4935de4f495e Author: Sandor Molnar <[email protected]> AuthorDate: Mon Aug 10 20:28:14 2026 +0200 KNOX-3414: security hardening for the OIDC provider (Tier 1 + Tier 2) Fixes a batch of security and correctness findings from reviewing the squashed "Knox as OIDC Provider" feature. Reviewed and tested together. Tier 1 (critical): - 1.1 Authenticate the client on the auth-code token exchange. The JWTFederationFilter Bearer path forwards to the token endpoint without checking client_secret, so a stolen code could be redeemed by anyone holding some valid Knox JWT. The endpoint now independently binds redemption to the client: PKCE code_verifier (S256) when a challenge was stored, else a constant-time client_secret check. - 1.2 Validate the federated id_token (signature via the OP JWKS, expected issuer, audience, exp/nbf) before trusting any claim; fail closed when jwks.endpoint/issuer are not configured. - 1.3 Add knoxidf.client.registration.anonymous.allowed (default false): dynamic client registration refuses anonymous callers unless explicitly enabled. Sample knoxidf-ldap topology opts in to preserve open reg. - 1.4 Stop leaking custom claims across users: build a per-request copy of the claim map instead of mutating the shared singleton field. Tier 2 (high): - 2.1 Default issueTime to now in JWTokenAttributesBuilder so every token gets a correct iat (fixes iat=1970 on KnoxSSO cookies/assertions). - 2.2 Set SCOPE_ATTRIBUTE to the scope value (was a double getClaim -> null). - 2.3 Match redirect_uri on parsed URI components with a path/host boundary (fixes wildcard open-redirect via startsWith). - 2.5 Require S256 PKCE; reject plain. - 2.6 Use the Knox truststore for federated OP HTTP calls. - 2.7 Treat auto_consent as a server-side policy, not a client bypass. - 2.8 Escape username in LDAP DN/filter (Rdn.escapeValue). - 2.9 Require and validate state on the authorize flow. - 2.10 Null-guard the federated callback / registration and return 4xx. Tests: TokenResourceClientAuthTest, RegistrationResourceAnonymousGuardTest, FederatedOpConfigurationTest, JWTTokenTest iat assertion. Deferred to follow-up PRs: 1.5 (secrets at rest via AliasService), 2.4 + 2.11 (atomic single-use auth-code consume + schema migration across dialects; the auth-code replay window remains until 2.4 lands), Tier 3/4. Co-Authored-By: Claude Opus 4.8 <[email protected]> --- .../build/conf/topologies/knoxidf-ldap.xml | 7 + .../federation/jwt/filter/JWTFederationFilter.java | 2 +- .../gateway/service/knoxidf/AuthorizeResource.java | 151 +++++++++++++++++++-- .../service/knoxidf/RegistrationResource.java | 32 +++++ .../gateway/service/knoxidf/TokenResource.java | 87 ++++++++++-- .../knoxidf/userparams/LdapUserParamsProvider.java | 6 +- .../RegistrationResourceAnonymousGuardTest.java | 78 +++++++++++ .../knoxidf/TokenResourceClientAuthTest.java | 116 ++++++++++++++++ .../gateway/service/knoxtoken/TokenResource.java | 10 +- .../security/token/JWTokenAttributesBuilder.java | 5 +- .../services/security/token/impl/JWTTokenTest.java | 28 ++++ .../util/knoxidf/AuthorizeRequestMetadata.java | 7 + .../util/knoxidf/FederatedOpConfiguration.java | 24 ++++ .../gateway/util/knoxidf/KnoxIDFConstants.java | 5 + .../util/knoxidf/FederatedOpConfigurationTest.java | 74 ++++++++++ 15 files changed, 603 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml index d25bb8869..f3f795467 100644 --- a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml +++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml @@ -59,6 +59,13 @@ <name>knoxidf.knox.token.limit.per.user</name> <value>-1</value> </param> + <param> + <!-- This sample topology intentionally allows open, unauthenticated client + registration (the endpoint is wired as 'anon' above). Registration refuses + anonymous callers unless this is explicitly set to true. --> + <name>knoxidf.client.registration.anonymous.allowed</name> + <value>true</value> + </param> <param> <name>token.exchange.topology.name</name> <value>knoxidf-token</value> diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java index 5109a2c09..5825c38a6 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java @@ -258,7 +258,7 @@ public class JWTFederationFilter extends AbstractJWTFilter { request.setAttribute(KnoxIDFConstants.TOKEN_ID_ATTRIBUTE, TokenUtils.getTokenId(token)); final String scope = token.getClaim(KnoxIDFConstants.SCOPE); if (scope != null) { - request.setAttribute(KnoxIDFConstants.SCOPE_ATTRIBUTE, token.getClaim(scope)); + request.setAttribute(KnoxIDFConstants.SCOPE_ATTRIBUTE, scope); } final String issuer = token.getIssuer(); if (issuer != null) { diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java index 94415de19..4fd703c7a 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java @@ -34,11 +34,15 @@ import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase; import org.apache.knox.gateway.services.GatewayServices; import org.apache.knox.gateway.services.ServiceLifecycleException; import org.apache.knox.gateway.services.ServiceType; +import org.apache.http.ssl.SSLContexts; import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity; import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService; import org.apache.knox.gateway.services.security.AliasServiceException; +import org.apache.knox.gateway.services.security.KeystoreService; +import org.apache.knox.gateway.services.security.token.JWTokenAuthority; import org.apache.knox.gateway.services.security.token.TokenMetadata; import org.apache.knox.gateway.services.security.token.TokenMetadataType; +import org.apache.knox.gateway.services.security.token.TokenServiceException; import org.apache.knox.gateway.services.security.token.UnknownTokenException; import org.apache.knox.gateway.services.security.token.impl.JWT; import org.apache.knox.gateway.services.security.token.impl.JWTToken; @@ -58,14 +62,17 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; +import javax.net.ssl.SSLContext; import java.io.UnsupportedEncodingException; import java.net.URI; +import java.security.KeyStore; +import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.text.ParseException; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -87,6 +94,7 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.DEFAULT_SCOP import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.FEDERATED_IDENTITY_ID; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.NONCE; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.OFFLINE_ACCESS_SCOPE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.PKCE_METHOD_S256; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URIS; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.RESPONSE_TYPE; @@ -114,6 +122,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { private ServletContext servletContext; private FederatedIdentityService federatedIdentityService; + private boolean autoConsentEnabled; @PostConstruct @Override @@ -122,6 +131,10 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { this.authorizeRequestMetadataStore = AuthorizeRequestMetadataStore.getInstance(tokenTTL); final GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE); federatedIdentityService = services.getService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE); + // Skipping user consent is a server-side deployment decision, never a client-supplied + // request parameter: a client must not be able to bypass the consent screen by sending + // auto_consent=true. + this.autoConsentEnabled = "true".equalsIgnoreCase(servletContext.getInitParameter("knoxidf.auto.consent.enabled")); } @Override @@ -159,7 +172,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { } if (!hasConsent(authorizeRequestMetadata)) { - if ("true".equalsIgnoreCase(request.getParameter("auto_consent"))) { + if (autoConsentEnabled) { markConsentAccepted(authorizeRequestMetadata); } else { final String consentAuthState = UUID.randomUUID().toString(); @@ -224,11 +237,29 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { //This is the callback for the federated OP final String federatedAuthCode = request.getParameter(CODE); final String state = request.getParameter(STATE); + if (StringUtils.isBlank(state) || StringUtils.isBlank(federatedAuthCode)) { + return error("invalid_request", "Missing state or code"); + } final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state); - //at this point, there has to be exactly 1 federated OP config - final FederatedOpConfiguration federatedOpConfiguration = federatedOpConfigurationStore.get(state).stream().findFirst().get(); + if (authorizeRequestMetadata == null) { + return error("invalid_request", "Unknown or expired state"); + } + final Set<FederatedOpConfiguration> opConfigs = federatedOpConfigurationStore.get(state); + final FederatedOpConfiguration federatedOpConfiguration = opConfigs == null ? null : opConfigs.stream().findFirst().orElse(null); + if (federatedOpConfiguration == null) { + return error("invalid_request", "No federated OP configuration available for the request"); + } final Pair<String, String> federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration); - final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedTokens.getLeft(), federatedOpConfiguration.getName()); + if (StringUtils.isBlank(federatedTokens.getLeft())) { + return error("invalid_request", "Federated OP did not return an id_token"); + } + final JWT federatedIdToken = new JWTToken(federatedTokens.getLeft()); + // Verify the OP's id_token (signature/issuer/audience/expiry) before trusting any claim in it. + final Response validationError = validateFederatedIdToken(federatedIdToken, federatedOpConfiguration); + if (validationError != null) { + return validationError; + } + final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName()); return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight())); } @@ -272,7 +303,8 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { } if (StringUtils.isNotBlank(authorizeRequestMetadata.getCodeChallenge())) { authCodeTokenMap.put(CODE_CHALLENGE, authorizeRequestMetadata.getCodeChallenge()); - authCodeTokenMap.put(CODE_CHALLENGE_METHOD, StringUtils.defaultIfBlank(authorizeRequestMetadata.getCodeChallengeMethod(), "plain")); + // Method is validated to be S256 in verifyParams; store it as-is (no 'plain' default). + authCodeTokenMap.put(CODE_CHALLENGE_METHOD, authorizeRequestMetadata.getCodeChallengeMethod()); } if (federatedTokens != null) { authCodeTokenMap.put(FEDERATED_IDENTITY_ID, federatedTokens.getLeft()); @@ -313,17 +345,35 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { return error("invalid_scope", "One or more requested scopes are not allowed"); } + // PKCE: only the S256 challenge method is supported. 'plain' (and an unspecified method, + // which OAuth would default to 'plain') offers no protection and is rejected. + if (StringUtils.isNotBlank(authorizeRequestMetadata.getCodeChallenge()) + && !PKCE_METHOD_S256.equals(authorizeRequestMetadata.getCodeChallengeMethod())) { + return error("invalid_request", "Unsupported code_challenge_method; only S256 is supported"); + } + return null; } return basicVerificationResponse; } private boolean matchesRedirectUri(String requestedUri, Set<String> registeredUris) { + final URI requested = parseUri(requestedUri); + if (requested == null) { + return false; + } for (String registered : registeredUris) { if (registered.endsWith("*")) { - String prefix = registered.substring(0, registered.length() - 1); - if (requestedUri.startsWith(prefix)) { - return true; + // Wildcard is a path-prefix match, but the origin (scheme/host/port) must match + // exactly. Comparing parsed components prevents a bare startsWith from letting + // "https://good.example*" match "https://good.example.evil.com". + final URI base = parseUri(registered.substring(0, registered.length() - 1)); + if (base != null && sameOrigin(base, requested)) { + final String basePath = base.getPath() == null ? "" : base.getPath(); + final String reqPath = requested.getPath() == null ? "" : requested.getPath(); + if (reqPath.startsWith(basePath)) { + return true; + } } } else if (registered.equals(requestedUri)) { return true; @@ -332,6 +382,23 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { return false; } + private static URI parseUri(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return new URI(value); + } catch (URISyntaxException e) { + return null; + } + } + + private static boolean sameOrigin(URI a, URI b) { + return a.getScheme() != null && a.getScheme().equalsIgnoreCase(b.getScheme()) + && a.getHost() != null && a.getHost().equalsIgnoreCase(b.getHost()) + && a.getPort() == b.getPort(); + } + private Pair<String, String> exchangeFederatedAuthCodeToTokens(String federatedAuthCode, FederatedOpConfiguration opConfig) { String federatedIdToken = null; String federatedAccessToken = null; @@ -354,7 +421,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { params.add(new BasicNameValuePair(CLIENT_ID, opConfig.getClientId())); params.add(new BasicNameValuePair(CLIENT_SECRET, opConfig.getClientSecret())); - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + try (CloseableHttpClient httpClient = createFederatedHttpClient()) { HttpPost post = new HttpPost(opConfig.getTokenEndpoint()); post.setHeader("Content-Type", "application/x-www-form-urlencoded"); post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); @@ -369,8 +436,68 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { } } - private FederatedIdentity resolveFederatedIdentity(String federatedIdToken, String opName) throws ParseException { - final JWT jwt = new JWTToken(federatedIdToken); + /** + * Builds the HTTP client used for the back-channel token request to the federated OP. The + * OP's TLS certificate must be validated against the Gateway's configured truststore + * ({@code gateway.truststore.*}) rather than the process-wide default, so this mirrors the + * outbound-dispatch clients. When no Gateway truststore is configured we fall back to the + * default client (JVM default trust material), never to an unvalidated client. + */ + private CloseableHttpClient createFederatedHttpClient() throws Exception { + final KeystoreService keystoreService = getGatewayServices().getService(ServiceType.KEYSTORE_SERVICE); + final KeyStore trustStore = keystoreService.getTruststoreForHttpClient(); + if (trustStore != null) { + final SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null).build(); + return HttpClients.custom().setSSLContext(sslContext).build(); + } + return HttpClients.createDefault(); + } + + /** + * Verifies the federated OP's id_token before any claim in it is trusted: + * <ul> + * <li>signature against the OP's JWKS and {@code exp}/{@code nbf} (via {@link JWTokenAuthority});</li> + * <li>{@code iss} equals the configured OP issuer;</li> + * <li>{@code aud} contains our client_id registered at the OP.</li> + * </ul> + * Fails closed: if the OP is not configured with a JWKS endpoint, expected issuer and client_id, + * the token cannot be verified and the federated login is refused. + * + * @return an error {@link Response} if verification fails, or {@code null} if the token is valid. + */ + private Response validateFederatedIdToken(final JWT idToken, final FederatedOpConfiguration opConfig) { + final String jwksEndpoint = opConfig.getJwksEndpoint(); + final String expectedIssuer = opConfig.getIssuer(); + final String expectedAudience = opConfig.getClientId(); + if (StringUtils.isBlank(jwksEndpoint) || StringUtils.isBlank(expectedIssuer) || StringUtils.isBlank(expectedAudience)) { + return error("invalid_request", "Federated OP is missing jwks.endpoint/issuer/clientId configuration; cannot verify id_token"); + } + + try { + final JWTokenAuthority authority = getGatewayServices().getService(ServiceType.TOKEN_SERVICE); + // Verifies the signature against the OP's JWKS and checks exp/nbf. + if (!authority.verifyToken(idToken, Collections.singleton(new URI(jwksEndpoint)), opConfig.getSignatureAlgorithm(), null)) { + return error("invalid_request", "Federated id_token signature or expiry verification failed"); + } + } catch (URISyntaxException e) { + return error("invalid_request", "Invalid jwks.endpoint configured for federated OP"); + } catch (TokenServiceException e) { + return error("invalid_request", "Federated id_token verification error"); + } + + if (!expectedIssuer.equals(idToken.getIssuer())) { + return error("invalid_request", "Federated id_token issuer mismatch"); + } + + final String[] audiences = idToken.getAudienceClaims(); + if (audiences == null || !Arrays.asList(audiences).contains(expectedAudience)) { + return error("invalid_request", "Federated id_token audience mismatch"); + } + + return null; + } + + private FederatedIdentity resolveFederatedIdentity(final JWT jwt, String opName) { final String issuer = jwt.getIssuer(); final String subject = jwt.getSubject(); return federatedIdentityService.findByProviderAndSubject(opName.toUpperCase(Locale.US), issuer, subject).orElseGet(() -> persistFederatedIdentity(jwt, opName)); diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java index 05675e491..ed3ad7411 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java @@ -18,6 +18,7 @@ package org.apache.knox.gateway.service.knoxidf; import com.nimbusds.jose.KeyLengthException; import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.security.SubjectUtils; import org.apache.knox.gateway.service.knoxtoken.ClientCredentialsResource; import org.apache.knox.gateway.services.ServiceLifecycleException; import org.apache.knox.gateway.services.security.AliasServiceException; @@ -25,12 +26,14 @@ import org.apache.knox.gateway.services.security.token.TokenMetadata; import org.glassfish.jersey.process.internal.RequestScoped; import javax.annotation.PostConstruct; +import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.ws.rs.Consumes; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.net.URI; @@ -41,6 +44,7 @@ import java.util.List; import java.util.Map; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_REGISTRATION_ANONYMOUS_ALLOWED; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.DEFAULT_SCOPES; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error; @@ -49,13 +53,22 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error; public class RegistrationResource extends ClientCredentialsResource { static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/client"; + private static final String ANONYMOUS_PRINCIPAL = "anonymous"; + private List<String> redirectUris; private List<String> allowedScopes; + boolean anonymousRegistrationAllowed; + + @Context + private ServletContext servletContext; @PostConstruct @Override public void init() throws ServletException, AliasServiceException, ServiceLifecycleException, KeyLengthException { super.init(); + // Secure by default: unless the deployment explicitly opts in, an anonymous caller cannot + // register a client even when the topology wires this endpoint as 'anon'. + this.anonymousRegistrationAllowed = Boolean.parseBoolean(servletContext.getInitParameter(CLIENT_REGISTRATION_ANONYMOUS_ALLOWED)); } @Override @@ -75,6 +88,13 @@ public class RegistrationResource extends ClientCredentialsResource { @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response registerClient(@FormParam("redirect_uris") String redirectUris, @FormParam("allowed_scopes") String allowedScopes) { + if (anonymousRegistrationDenied()) { + return error("access_denied", "Anonymous client registration is disabled. Set '" + + CLIENT_REGISTRATION_ANONYMOUS_ALLOWED + "' to true in the KNOXIDF service configuration to enable it."); + } + if (StringUtils.isBlank(redirectUris)) { + return error("invalid_request", "redirect_uris must be provided"); + } this.redirectUris = Arrays.asList(redirectUris.split(",")); final Response redirectUriVerificationResponse = verifyRedirectUris(); if (redirectUriVerificationResponse != null) { @@ -92,6 +112,18 @@ public class RegistrationResource extends ClientCredentialsResource { return super.doPost(); } + /** + * @return {@code true} when the request must be rejected because an anonymous caller is + * attempting to register a client while open registration has not been explicitly enabled. + */ + boolean anonymousRegistrationDenied() { + return !anonymousRegistrationAllowed && isAnonymousCaller(); + } + + private boolean isAnonymousCaller() { + return ANONYMOUS_PRINCIPAL.equalsIgnoreCase(SubjectUtils.getCurrentEffectivePrincipalName()); + } + private Response verifyRedirectUris() { if (redirectUris == null || redirectUris.isEmpty()) { return error("invalid_request", "redirect_uris must be provided"); diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java index 54e697eb9..97e24378c 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java @@ -18,6 +18,7 @@ package org.apache.knox.gateway.service.knoxidf; import com.nimbusds.jose.KeyLengthException; import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory; import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase; @@ -26,6 +27,7 @@ import org.apache.knox.gateway.services.ServiceLifecycleException; import org.apache.knox.gateway.services.ServiceType; import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity; import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService; +import org.apache.knox.gateway.services.security.AliasService; import org.apache.knox.gateway.services.security.AliasServiceException; import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder; import org.apache.knox.gateway.services.security.token.JWTokenAuthority; @@ -35,6 +37,7 @@ import org.apache.knox.gateway.services.security.token.TokenServiceException; import org.apache.knox.gateway.services.security.token.TokenUtils; import org.apache.knox.gateway.services.security.token.UnknownTokenException; import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; import org.apache.knox.gateway.util.ServletRequestUtils; import javax.annotation.PostConstruct; @@ -55,6 +58,7 @@ import java.util.Base64; import java.util.HashMap; import java.util.Map; +import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.AUTH_CODE; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH; @@ -65,7 +69,6 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLEN import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_VERIFIER; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.FEDERATED_IDENTITY_ID; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.OFFLINE_ACCESS_SCOPE; -import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.PKCE_METHOD_PLAIN; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.PKCE_METHOD_S256; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN; @@ -88,6 +91,7 @@ public class TokenResource extends PasscodeTokenResourceBase { private FederatedIdentityService federatedIdentityService; private long refreshTokenTTL; + TokenMAC tokenMAC; @Override public String getPrefix() { @@ -102,6 +106,13 @@ public class TokenResource extends PasscodeTokenResourceBase { this.userParamsProvider = UserParamsProviderFactory.getUserParamsProvider(servletContext); final GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE); federatedIdentityService = services.getService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE); + // Build the same passcode MAC the JWTFederationFilter uses so the token endpoint can + // independently authenticate a client_secret (see validateAuthCode). The HMAC key alias is + // guaranteed to exist by this point (PasscodeTokenResourceBase#setupTokenStateService + // generates it if absent). + final GatewayConfig gatewayConfig = (GatewayConfig) servletContext.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + final AliasService aliasService = services.getService(ServiceType.ALIAS_SERVICE); + this.tokenMAC = new TokenMAC(gatewayConfig.getKnoxTokenHashAlgorithm(), aliasService.getPasswordFromAliasForGateway(TokenMAC.KNOX_TOKEN_HASH_KEY_ALIAS_NAME)); setRefreshTokenTTL(); } @@ -276,15 +287,22 @@ public class TokenResource extends PasscodeTokenResourceBase { throw new AuthTokenValidationError("Invalid auth_code: expired"); } else if (!associateRedirectUri.equals(redirectUri)) { throw new AuthTokenValidationError("Invalid redirect_uri: " + redirectUri); - } else { - final String associatedClientId = authCodeTokenMetadata.getMetadata(CLIENT_ID); - final String clientId = getRequestParam(CLIENT_ID); - if (!associatedClientId.equals(clientId)) { - throw new AuthTokenValidationError("Invalid client_id: " + clientId); - } } - // PKCE validation + final String associatedClientId = authCodeTokenMetadata.getMetadata(CLIENT_ID); + final String clientId = getRequestParam(CLIENT_ID); + if (!associatedClientId.equals(clientId)) { + throw new AuthTokenValidationError("Invalid client_id: " + clientId); + } + + // Client authentication (defense in depth). A stolen auth code must not be redeemable by + // a party that merely holds some valid Knox JWT: the JWTProvider (JWTFederationFilter) + // Bearer path forwards such a request to this endpoint without ever checking + // client_secret. So the token endpoint independently binds the redemption to the + // legitimate client here. The caller must prove client identity via EITHER: + // - PKCE: a code_verifier matching the challenge stored at authorize time (S256), or + // - the client's client_secret (constant-time compared against the stored passcode). + // A code is rejected when neither is satisfiable. final String codeChallenge = authCodeTokenMetadata.getMetadata(CODE_CHALLENGE); if (StringUtils.isNotBlank(codeChallenge)) { final String codeChallengeMethod = authCodeTokenMetadata.getMetadata(CODE_CHALLENGE_METHOD); @@ -295,16 +313,63 @@ public class TokenResource extends PasscodeTokenResourceBase { if (!validatePKCE(codeVerifier, codeChallenge, codeChallengeMethod)) { throw new AuthTokenValidationError("Invalid code_verifier"); } + } else if (!isValidClientSecret(clientId, getRequestParam(CLIENT_SECRET))) { + throw new AuthTokenValidationError("Invalid client authentication"); } } catch (UnknownTokenException e) { throw new AuthTokenValidationError("Unknown auth_code"); } } + /** + * Authenticates a confidential client on the token endpoint by validating the presented + * {@code client_secret} against the stored passcode of the client identified by {@code clientId}. + * <p> + * The wire format of {@code client_secret} matches what registration returns and what + * {@link org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter} expects: + * {@code Base64(Base64(tokenId)::Base64(rawPasscode))}. The embedded {@code tokenId} must equal + * {@code clientId}, and {@code HMAC(tokenId, issueTime, userName, rawPasscode)} must equal the + * stored passcode hash. The comparison is constant-time. + * + * @return {@code true} only if the secret is well-formed, bound to {@code clientId}, and matches. + */ + boolean isValidClientSecret(final String clientId, final String clientSecret) { + if (StringUtils.isBlank(clientId) || StringUtils.isBlank(clientSecret)) { + return false; + } + try { + final String[] tokenIdAndPasscode = decodeBase64(clientSecret).split("::"); + if (tokenIdAndPasscode.length != 2) { + return false; + } + final String tokenId = decodeBase64(tokenIdAndPasscode[0]); + final String rawPasscode = decodeBase64(tokenIdAndPasscode[1]); + // The client_secret must belong to exactly the client redeeming the code. + if (!tokenId.equals(clientId)) { + return false; + } + final TokenMetadata clientMetadata = tokenStateService.getTokenMetadata(tokenId); + final String storedPasscode = clientMetadata == null ? null : clientMetadata.getPasscode(); + if (StringUtils.isBlank(storedPasscode)) { + return false; + } + final long issueTime = tokenStateService.getTokenIssueTime(tokenId); + final String userName = clientMetadata.getUserName(); + final byte[] computed = tokenMAC.hash(tokenId, issueTime, userName, rawPasscode).getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(computed, storedPasscode.getBytes(StandardCharsets.UTF_8)); + } catch (UnknownTokenException | RuntimeException e) { + return false; + } + } + + private String decodeBase64(final String value) { + return new String(Base64.getDecoder().decode(value.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + } + private boolean validatePKCE(String codeVerifier, String codeChallenge, String method) { - if (PKCE_METHOD_PLAIN.equals(method)) { - return codeVerifier.equals(codeChallenge); - } else if (PKCE_METHOD_S256.equals(method)) { + // Only S256 is supported. 'plain' provides no protection and is rejected (the authorize + // endpoint already refuses to store a non-S256 challenge; this is defense in depth). + if (PKCE_METHOD_S256.equals(method)) { try { return generateS256Challenge(codeVerifier).equals(codeChallenge); } catch (NoSuchAlgorithmException e) { diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java index a17248451..080cc32de 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java @@ -30,6 +30,7 @@ import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import javax.naming.ldap.InitialLdapContext; import javax.naming.ldap.LdapContext; +import javax.naming.ldap.Rdn; import javax.servlet.ServletContext; import java.util.ArrayList; import java.util.HashMap; @@ -98,7 +99,10 @@ public class LdapUserParamsProvider implements UserParamsProvider { try { ctx = createSystemContext(); - String userDn = String.format(Locale.US, ldapUserDnTemplate, subjectName); + // Escape the subject before interpolating it into the DN template. Without escaping a + // crafted subject (e.g. "x,ou=admins") could inject additional DN components (LDAP + // injection). Rdn.escapeValue escapes the RDN value per RFC 2253. + String userDn = String.format(Locale.US, ldapUserDnTemplate, Rdn.escapeValue(subjectName)); SearchControls controls = new SearchControls(); controls.setSearchScope(SearchControls.OBJECT_SCOPE); diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java new file mode 100644 index 000000000..11a84b4c2 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you 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 + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * 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 org.apache.knox.gateway.service.knoxidf; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.security.PrivilegedAction; + +import javax.security.auth.Subject; + +import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.junit.Test; + +/** + * Verifies finding 1.3: dynamic client registration refuses anonymous callers unless the + * deployment explicitly opts in via {@code knoxidf.client.registration.anonymous.allowed}. The + * endpoint is wired as {@code anon} in the sample topologies, so this resource-level check is what + * keeps registration closed by default. + */ +public class RegistrationResourceAnonymousGuardTest { + + /** Exposes injection of the opt-in flag without running the full JAX-RS/servlet lifecycle. */ + static final class TestableRegistrationResource extends RegistrationResource { + TestableRegistrationResource(final boolean anonymousRegistrationAllowed) { + this.anonymousRegistrationAllowed = anonymousRegistrationAllowed; + } + } + + private static boolean deniedAs(final String principalName, final boolean anonymousAllowed) { + final TestableRegistrationResource resource = new TestableRegistrationResource(anonymousAllowed); + if (principalName == null) { + // No security context at all. + return resource.anonymousRegistrationDenied(); + } + final Subject subject = new Subject(); + subject.getPrincipals().add(new PrimaryPrincipal(principalName)); + return Subject.doAs(subject, (PrivilegedAction<Boolean>) resource::anonymousRegistrationDenied); + } + + @Test + public void testAnonymousCallerRejectedByDefault() { + // Default (flag false): the AnonymousAuthFilter principal ("anonymous") must be turned away. + assertTrue(deniedAs("anonymous", false)); + } + + @Test + public void testAnonymousCallerAllowedWhenExplicitlyEnabled() { + // The deliberate open-registration deployment mode: opt in and the anonymous caller is allowed. + assertFalse(deniedAs("anonymous", true)); + } + + @Test + public void testAuthenticatedCallerAlwaysAllowed() { + // A real authenticated principal is never subject to the anonymous gate, regardless of the flag. + assertFalse(deniedAs("alice", false)); + assertFalse(deniedAs("alice", true)); + } + + @Test + public void testAnonymousMatchIsCaseInsensitive() { + assertTrue(deniedAs("ANONYMOUS", false)); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java new file mode 100644 index 000000000..7cde79747 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you 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 + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * 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 org.apache.knox.gateway.service.knoxidf; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that the token endpoint independently authenticates a confidential client on the + * authorization_code grant (finding 1.1). This closes the gap where the JWTFederationFilter Bearer + * path forwards a request without checking client_secret: a stolen auth code must not be redeemable + * without proving client identity. + */ +public class TokenResourceClientAuthTest { + + private static final String CLIENT_ID = "client-abc"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + private TokenMAC tokenMAC; + + private TestableTokenResource resource; + + /** + * Exposes injection of the inherited (protected) token-state service and the package-private MAC + * so the client-authentication logic can be exercised without the full JAX-RS/servlet lifecycle. + */ + static final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tokenStateService, final TokenMAC mac) { + this.tokenStateService = tokenStateService; + this.tokenMAC = mac; + } + } + + @Before + public void setUp() throws Exception { + // A deterministic MAC shared by "the server" (stored hash) and the resource under test. + tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT_ID, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT_ID)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT_ID)).andReturn(ISSUE_TIME).anyTimes(); + EasyMock.replay(tokenStateService); + + resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC); + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testValidClientSecretIsAccepted() { + assertTrue(resource.isValidClientSecret(CLIENT_ID, wireSecret(CLIENT_ID, RAW_PASSCODE))); + } + + @Test + public void testWrongPasscodeIsRejected() { + assertFalse(resource.isValidClientSecret(CLIENT_ID, wireSecret(CLIENT_ID, "not-the-real-passcode"))); + } + + @Test + public void testSecretBoundToDifferentClientIsRejected() { + // A secret whose embedded tokenId does not match the client_id redeeming the code must fail, + // even if the secret itself is otherwise well-formed. + assertFalse(resource.isValidClientSecret("some-other-client", wireSecret(CLIENT_ID, RAW_PASSCODE))); + } + + @Test + public void testBlankSecretIsRejected() { + assertFalse(resource.isValidClientSecret(CLIENT_ID, null)); + assertFalse(resource.isValidClientSecret(CLIENT_ID, "")); + } + + @Test + public void testMalformedSecretIsRejected() { + // Not base64 / no "tokenId::passcode" structure. + assertFalse(resource.isValidClientSecret(CLIENT_ID, "!!!not-base64!!!")); + assertFalse(resource.isValidClientSecret(CLIENT_ID, + Base64.getEncoder().encodeToString("no-separator".getBytes(StandardCharsets.UTF_8)))); + } +} diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java index ca59dc1eb..7b714d343 100644 --- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java +++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java @@ -1169,12 +1169,16 @@ public class TokenResource { handleDelegatedAuthentication(subject, jwtAttributesBuilder); } + // This resource is a @Singleton, so hardCodedClaimMappings is shared across all requests and + // must never be mutated per-request. Merge the topology-configured mappings with this request's + // user params into a fresh map; otherwise one user's params would leak into other users' tokens. + final Map<String, Object> customAttributes = new HashMap<>(hardCodedClaimMappings); if (userContext.userParams != null) { - hardCodedClaimMappings.putAll(userContext.userParams); + customAttributes.putAll(userContext.userParams); } - if (!hardCodedClaimMappings.isEmpty()) { - jwtAttributesBuilder.setCustomAttributes(hardCodedClaimMappings); + if (!customAttributes.isEmpty()) { + jwtAttributesBuilder.setCustomAttributes(customAttributes); } jwtAttributes = jwtAttributesBuilder.build(); diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java index 8bc70d43d..1e4fb96b5 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java @@ -28,7 +28,10 @@ public class JWTokenAttributesBuilder { private String userName; private List<String> audiences; private String algorithm; - private long issueTime; + // Default to the builder's creation time so every issued token carries a correct 'iat'. + // Callers that need a specific issue time (e.g. managed-token flows) override this via + // setIssueTime(). Without this default, JWTToken would emit iat=epoch-0 (1970). + private long issueTime = System.currentTimeMillis(); private long expires; private String signingKeystoreName; private String signingKeystoreAlias; diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java index c4ab7d7ba..becb9c32c 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java @@ -86,6 +86,34 @@ public class JWTTokenTest { assertTrue("Missing ALG claim in JWT header", token.getHeader().contains(ALGO)); } + @Test + public void testIssueTimeDefaultsToNow() throws Exception { + // Regression: JWTToken always emits 'iat'. When a caller does not set the issue time, the + // builder must default it to "now" rather than leaving it at epoch-0 (1970). + final long before = System.currentTimeMillis(); + final JWT token = new JWTToken(new JWTokenAttributesBuilder() + .setUserName("[email protected]").setAlgorithm("RS256").build()); + final long after = System.currentTimeMillis(); + + final Date issueTime = token.getJWTClaimsSet().getIssueTime(); + assertNotNull("iat must be present", issueTime); + // JWT 'iat' has second precision, so allow the surrounding second as slack. + assertTrue("iat must be ~now, not 1970 (was " + issueTime + ")", + issueTime.getTime() >= (before - 1000L) && issueTime.getTime() <= (after + 1000L)); + } + + @Test + public void testIssueTimeIsHonouredWhenSet() throws Exception { + final long explicit = 1_600_000_000_000L; // 2020-09-13 + final JWT token = new JWTToken(new JWTokenAttributesBuilder() + .setUserName("[email protected]").setAlgorithm("RS256").setIssueTime(explicit).build()); + + final Date issueTime = token.getJWTClaimsSet().getIssueTime(); + assertNotNull(issueTime); + // second precision + assertEquals(explicit / 1000L, issueTime.getTime() / 1000L); + } + @Test public void testPrivateUUIDClaim() throws Exception { JWT token = new JWTToken(new JWTokenAttributesBuilder().setAudiences(singletonList("https://login.example.com")).setUserName("[email protected]").setAlgorithm("RS256").build()); diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java index 239e0baf2..4f01eaddf 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java @@ -71,6 +71,13 @@ public final class AuthorizeRequestMetadata { return error("invalid_request", "Missing redirect_uri"); } + // Require state for CSRF protection: it is echoed back on the redirect and the client + // must match it against the value it generated. Without it the auth-code flow is open to + // login-CSRF, and redirectToAuthSuccess would NPE URL-encoding a null state. + if (state == null || state.isEmpty()) { + return error("invalid_request", "Missing state"); + } + // Verify scope(s) if (requestedScopes == null || requestedScopes.isEmpty()) { return error("invalid_scope", "Missing scopes"); diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java index 7ad607843..8ebdea5b1 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java @@ -28,6 +28,12 @@ public class FederatedOpConfiguration { private final String userInfoEndpoint; private final String discoveryEndpoint; private final String authorizeCallback; + private final String jwksEndpoint; + private final String issuer; + private final String signatureAlgorithm; + + // Default signature algorithm expected for the OP's id_token when not explicitly configured. + static final String DEFAULT_SIGNATURE_ALGORITHM = "RS256"; public FederatedOpConfiguration(final ServletContext servletContext, final String opName) { this.name = opName; @@ -40,6 +46,12 @@ public class FederatedOpConfiguration { this.authorizeCallback = servletContext.getInitParameter(prefix + "authorize.callback"); this.userInfoEndpoint = servletContext.getInitParameter(prefix + "userinfo.endpoint"); this.discoveryEndpoint = servletContext.getInitParameter(prefix + "discovery.endpoint"); + // Used to validate the OP's id_token (signature via JWKS, expected issuer). See + // AuthorizeResource#validateFederatedIdToken - federated login fails closed without these. + this.jwksEndpoint = servletContext.getInitParameter(prefix + "jwks.endpoint"); + this.issuer = servletContext.getInitParameter(prefix + "issuer"); + final String configuredAlg = servletContext.getInitParameter(prefix + "signature.algorithm"); + this.signatureAlgorithm = configuredAlg == null || configuredAlg.isEmpty() ? DEFAULT_SIGNATURE_ALGORITHM : configuredAlg; } public String getName() { @@ -78,4 +90,16 @@ public class FederatedOpConfiguration { return discoveryEndpoint; } + public String getJwksEndpoint() { + return jwksEndpoint; + } + + public String getIssuer() { + return issuer; + } + + public String getSignatureAlgorithm() { + return signatureAlgorithm; + } + } diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java index d757b573f..aabb85684 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java @@ -59,6 +59,11 @@ public interface KnoxIDFConstants { String TOKEN_EXCHANGE_TOPOLOGY_NAME = "token.exchange.topology.name"; + // When false (the default), the dynamic client-registration endpoint refuses anonymous callers + // even if the topology wires it as 'anon'. Deployments that intend open, unauthenticated + // registration must explicitly set this to true (see the sample knoxidf topologies). + String CLIENT_REGISTRATION_ANONYMOUS_ALLOWED = "knoxidf.client.registration.anonymous.allowed"; + // TrustedOidcIssuerService gateway-level params (read from GatewayConfig / gateway-site.xml) String TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS = "gateway.trustedoidcissuer.discovery.cache.ttl.secs"; diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java new file mode 100644 index 000000000..cfe74d8d9 --- /dev/null +++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you 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 + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * 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 org.apache.knox.gateway.util.knoxidf; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import javax.servlet.ServletContext; + +import org.easymock.EasyMock; +import org.junit.Test; + +public class FederatedOpConfigurationTest { + + private static final String OP = "keycloak"; + private static final String PREFIX = KnoxIDFConstants.FEDERATED_OP_CONFIG_PREFIX + OP + "."; + + @Test + public void testIdTokenVerificationParamsAreRead() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getInitParameter(PREFIX + "jwks.endpoint")).andReturn("https://op.example/jwks").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "issuer")).andReturn("https://op.example/realms/knox").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "signature.algorithm")).andReturn("RS512").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "clientId")).andReturn("knox-client").anyTimes(); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + assertEquals("https://op.example/jwks", config.getJwksEndpoint()); + assertEquals("https://op.example/realms/knox", config.getIssuer()); + assertEquals("RS512", config.getSignatureAlgorithm()); + assertEquals("knox-client", config.getClientId()); + } + + @Test + public void testSignatureAlgorithmDefaultsToRS256() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + // No signature.algorithm configured -> the default (RS256) must be used. + EasyMock.expect(context.getInitParameter(PREFIX + "signature.algorithm")).andReturn(null).anyTimes(); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + assertEquals(FederatedOpConfiguration.DEFAULT_SIGNATURE_ALGORITHM, config.getSignatureAlgorithm()); + assertEquals("RS256", config.getSignatureAlgorithm()); + } + + @Test + public void testVerificationParamsAbsentByDefault() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + // When nothing is configured the id_token verification inputs are null, which makes the + // federated login fail closed in AuthorizeResource#validateFederatedIdToken. + assertNull(config.getJwksEndpoint()); + assertNull(config.getIssuer()); + } +}
