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 e43cd2e5204de14976ff9612e03cc52e1914dc64 Author: Sandor Molnar <[email protected]> AuthorDate: Thu Aug 13 17:55:21 2026 +0200 KNOX-3414: support Authorization Code + PKCE for public clients - JWTFederationFilter: add AuthCode TokenType; forward authorization_code grant to the token endpoint with an anonymous subject so public PKCE clients (no client_secret) are not rejected - TokenResource: gate auth-code handling behind isAuthCodeFlow() and fall back to super.doPost() for other grants (no separate KNOXTOKEN needed) - RegistrationResource: add knoxidf.custom.loopback.hosts allowlist for plain-HTTP redirect_uri hosts beyond localhost/127.0.0.1/::1 - DiscoveryResource: append /register to advertised registration_endpoint - TokenResourceV2: make RESOURCE_PATH public - Tests for AuthCode pass-through and the loopback-host policy - Docs: Polaris and Polaris Console integration guides; ignore built site/ Co-Authored-By: Claude Opus 4.8 <[email protected]> --- .gitignore | 3 + .../federation/jwt/filter/JWTFederationFilter.java | 35 +- .../federation/OAuthFlowsFederationFilterTest.java | 74 +++ .../gateway/service/knoxidf/DiscoveryResource.java | 2 +- .../service/knoxidf/RegistrationResource.java | 38 +- .../gateway/service/knoxidf/TokenResource.java | 90 ++-- .../knoxidf/RegistrationRedirectUriPolicyTest.java | 57 ++- .../gateway/service/knoxtoken/TokenResourceV2.java | 2 +- .../gateway/util/knoxidf/KnoxIDFConstants.java | 7 + .../assets/images/knoxidf/polaris_console_home.png | Bin 0 -> 753395 bytes .../images/knoxidf/polaris_console_login.png | Bin 0 -> 174009 bytes knox-site/docs/knoxidf/index.md | 1 + knox-site/docs/knoxidf/integrations/polaris.md | 533 +++++++++++++++++++++ .../docs/knoxidf/integrations/polaris_console.md | 471 ++++++++++++++++++ knox-site/mkdocs.yml | 3 + 15 files changed, 1262 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index f28ecbe32..3d60d2dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ Thumbs.db # Test-generated keystore files (accidentally tracked; see KNOX-3328) gateway-server/data/security/keystores/*.jceks + +# Generated MkDocs build output +knox-site/site/ 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 5825c38a6..e87ffdd8b 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 @@ -86,7 +86,7 @@ public class JWTFederationFilter extends AbstractJWTFilter { public static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"; public enum TokenType { - JWT, Passcode, TokenExchange; + JWT, Passcode, TokenExchange, AuthCode; } public static final String KNOX_TOKEN_AUDIENCES = "knox.token.audiences"; @@ -208,6 +208,17 @@ public class JWTFederationFilter extends AbstractJWTFilter { return; } + // authorization_code grant: the KnoxIDF token endpoint (TokenResource) authenticates the client + // itself -- a PKCE code_verifier for public clients, or a client_secret for confidential clients + // -- and binds the code to its client_id and redirect_uri. getWireToken flags this via + // TokenType.AuthCode when the grant_type is in the request body and no Bearer/Basic credentials + // were presented. Forward to the service without a gateway-established token so that public PKCE + // clients (no secret) are not rejected here. + if (wireToken != null && TokenType.AuthCode.equals(wireToken.getLeft())) { + continueWithAuthorizationCodeGrant(request, response, chain); + return; + } + if (wireToken != null && wireToken.getLeft() != null && wireToken.getRight() != null) { TokenType tokenType = wireToken.getLeft(); String tokenValue = wireToken.getRight(); @@ -364,7 +375,11 @@ public class JWTFederationFilter extends AbstractJWTFilter { HttpServletRequest unwrappedRequest = ServletRequestUtils.unwrapHttpServletRequest(request); final String grantType = unwrappedRequest.getParameter(GRANT_TYPE); final String clientAssertionType = unwrappedRequest.getParameter(CLIENT_ASSERTION_TYPE); - if (CLIENT_CREDENTIALS.equals(grantType) || AUTH_CODE.equals(grantType)) { + if (AUTH_CODE.equals(grantType)) { + // no client_secret parsed here; the KnoxIDF token endpoint authenticates the client + // (see the TokenType.AuthCode handling in doFilter) + return Pair.of(TokenType.AuthCode, null); + } else if (CLIENT_CREDENTIALS.equals(grantType)) { if (CLIENT_ASSERTION_JWT_BEARER.equals(clientAssertionType)) { // short lived client assertion token expected return getClientTokenFromParams(unwrappedRequest, CLIENT_ASSERTION); @@ -538,6 +553,22 @@ public class JWTFederationFilter extends AbstractJWTFilter { } } + /** + * Forwards an {@code authorization_code} token request to the KnoxIDF token endpoint without a + * gateway-established token. The token endpoint ({@code TokenResource.validateAuthCode}) + * independently authenticates the client -- a PKCE {@code code_verifier} for public clients, or a + * {@code client_secret} for confidential clients -- and binds the code to its {@code client_id} + * and {@code redirect_uri}, so this filter only needs to let the request through with an anonymous + * subject. The principal of the issued token is derived from the authorization code's stored + * metadata, not from this subject. + */ + private void continueWithAuthorizationCodeGrant(final ServletRequest request, final ServletResponse response, final FilterChain chain) + throws ServletException, IOException { + final Subject subject = new Subject(); + subject.getPrincipals().add(new PrimaryPrincipal("anonymous")); + continueWithEstablishedSecurityContext(subject, (HttpServletRequest) request, (HttpServletResponse) response, chain); + } + /** * An exception indicating that cookies are present, but none of them contain a * valid JWT. diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java index 6fc873f68..3c635d50e 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java @@ -42,9 +42,11 @@ import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_CREDENTIALS; import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_ID; import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.security.CommonTokenConstants.AUTH_CODE; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import javax.servlet.http.HttpServletRequestWrapper; @@ -392,6 +394,78 @@ public class OAuthFlowsFederationFilterTest extends TokenIDAsHTTPBasicCredsFeder public void testPasscodeCannotBeReplayedAgainstDifferentTokenId() { } + @Test + public void testGetWireTokenUsingAuthorizationCodeFlowWithoutClientSecret() throws Exception { + // A public client redeeming an authorization code with PKCE sends grant_type=authorization_code + // with no Authorization header and no client_secret. Unlike client_credentials, the filter must + // NOT reject this; it flags TokenType.AuthCode so the request is forwarded to the KnoxIDF token + // endpoint, which authenticates the caller via the code_verifier. + final HttpServletRequest mockRequest = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(mockRequest.getHeader("Authorization")).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getParameter(GRANT_TYPE)).andReturn(AUTH_CODE).anyTimes(); + EasyMock.replay(mockRequest); + + // Wrap the request to simulate real-world scenario where wrappers hide parameter access + final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); + + handler.init(new TestFilterConfig(getProperties())); + final Pair<TokenType, String> wireToken = ((TestJWTFederationFilter) handler).getWireToken(request); + + EasyMock.verify(mockRequest); + + assertNotNull(wireToken); + assertEquals(TokenType.AuthCode, wireToken.getLeft()); + assertNull(wireToken.getRight()); + } + + @Test + public void testGetWireTokenUsingAuthorizationCodeFlowDoesNotParseClientSecret() throws Exception { + // Even when a (confidential) client includes a client_secret on the authorization_code grant, + // the filter routes it through the AuthCode pass-through and does NOT try to parse the secret as + // a passcode here -- the token endpoint validates it. A non-passcode-formatted secret that would + // have triggered INVALID_CLIENT_SECRET on the client_credentials path must not do so here. + final HttpServletRequest mockRequest = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(mockRequest.getHeader("Authorization")).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getParameter(GRANT_TYPE)).andReturn(AUTH_CODE).anyTimes(); + EasyMock.expect(mockRequest.getParameter(CLIENT_SECRET)).andReturn("not-a-passcode").anyTimes(); + EasyMock.replay(mockRequest); + + // Wrap the request to simulate real-world scenario where wrappers hide parameter access + final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); + + handler.init(new TestFilterConfig(getProperties())); + final Pair<TokenType, String> wireToken = ((TestJWTFederationFilter) handler).getWireToken(request); + + assertNotNull(wireToken); + assertEquals(TokenType.AuthCode, wireToken.getLeft()); + assertNull(wireToken.getRight()); + } + + @Test + public void testAuthorizationCodeFlowForwardsToServiceWithoutClientSecret() throws Exception { + // End-to-end at the filter level: a public PKCE client's authorization_code request is forwarded + // down the chain with an anonymous subject rather than rejected, so the KnoxIDF token endpoint can + // validate the code + code_verifier and issue the token. + final HttpServletRequest mockRequest = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(mockRequest.getHeader("Authorization")).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getParameter(GRANT_TYPE)).andReturn(AUTH_CODE).anyTimes(); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(mockRequest, response); + + // Wrap the request to simulate real-world scenario where wrappers hide parameter access + final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); + + handler.init(new TestFilterConfig(getProperties())); + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + assertTrue(chain.doFilterCalled); + Assert.assertNotNull(chain.subject); + } + @Test public void testGetWireTokenUsingRefreshTokenFlow() throws Exception { final String refreshToken = "WTJ4cFpXNTBMV2xrTFRFeU16UTE6OlkyeHBaVzUwTFhObFkzSmxkQzB4TWpNME5RPT0="; diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.java index 26b6a8cbe..276ca8e8a 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.java @@ -68,7 +68,7 @@ public class DiscoveryResource { config.put("userinfo_endpoint", userInfoEndpoint); // Dynamic client registration is served on the current topology (no token-exchange // substitution); advertise it so clients can discover it per OIDC Dynamic Client Registration. - config.put("registration_endpoint", baseUrl + RegistrationResource.RESOURCE_PATH); + config.put("registration_endpoint", baseUrl + RegistrationResource.RESOURCE_PATH + "/register"); config.put("jwks_uri", baseUrl + JwksResource.RESOURCE_PATH); config.put("response_types_supported", new String[]{KnoxIDFConstants.CODE}); // REQUIRED by OpenID Connect Discovery 1.0. Knox derives 'sub' as a deterministic UUIDv5 over 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 6cc02cd37..f9a8c6469 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 @@ -43,11 +43,15 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_REGISTRATION_ANONYMOUS_ALLOWED; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_REGISTRATION_CUSTOM_LOOPBACK_HOSTS; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.DEFAULT_SCOPES; import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error; @@ -57,10 +61,12 @@ public class RegistrationResource extends ClientCredentialsResource { static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/client"; private static final String ANONYMOUS_PRINCIPAL = "anonymous"; + static final Set<String> DEFAULT_LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1"); private List<String> redirectUris; private List<String> allowedScopes; boolean anonymousRegistrationAllowed; + Set<String> loopbackHosts; @Context private ServletContext servletContext; @@ -72,6 +78,23 @@ public class RegistrationResource extends ClientCredentialsResource { // 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)); + this.loopbackHosts = parseLoopbackHosts(servletContext.getInitParameter(CLIENT_REGISTRATION_CUSTOM_LOOPBACK_HOSTS)); + } + + // Build the loopback-host set: the hard-coded defaults plus any admin-configured extra hosts from the + // comma-separated config (trimmed, lowercased, blanks dropped). Null/blank config => defaults only. + static Set<String> parseLoopbackHosts(String customLoopbackHosts) { + if (StringUtils.isBlank(customLoopbackHosts)) { + return DEFAULT_LOOPBACK_HOSTS; + } + final Set<String> hosts = new HashSet<>(DEFAULT_LOOPBACK_HOSTS); + for (String h : customLoopbackHosts.split(",")) { + final String trimmed = h.trim(); + if (!trimmed.isEmpty()) { + hosts.add(trimmed.toLowerCase(Locale.ROOT)); + } + } + return hosts; } @Override @@ -146,12 +169,17 @@ public class RegistrationResource extends ClientCredentialsResource { } private Response verifyRedirectUris() { - return verifyRedirectUris(redirectUris); + return verifyRedirectUris(redirectUris, loopbackHosts); } // Package-private and list-parameterized so the redirect-URI policy (https-only except loopback, // no wildcard host, restricted path/query/fragment wildcards) is unit-testable in isolation. static Response verifyRedirectUris(List<String> redirectUris) { + return verifyRedirectUris(redirectUris, DEFAULT_LOOPBACK_HOSTS); + } + + // loopbackHosts: normalized (lowercase) hosts allowed to use a plain-HTTP redirect_uri. + static Response verifyRedirectUris(List<String> redirectUris, Set<String> loopbackHosts) { if (redirectUris == null || redirectUris.isEmpty()) { return error("invalid_request", "redirect_uris must be provided"); } @@ -173,7 +201,7 @@ public class RegistrationResource extends ClientCredentialsResource { // (localhost / 127.0.0.1 / ::1) native-app dev. Any other http:// redirect is rejected. final String scheme = uri.getScheme(); final boolean https = "https".equalsIgnoreCase(scheme); - final boolean loopbackHttp = "http".equalsIgnoreCase(scheme) && isLoopbackHost(uri.getHost()); + final boolean loopbackHttp = "http".equalsIgnoreCase(scheme) && isLoopbackHost(uri.getHost(), loopbackHosts); if (!https && !loopbackHttp) { return error("invalid_request", "Redirect URI must use HTTPS (plain HTTP allowed only for localhost): " + uriStr); } @@ -193,13 +221,15 @@ public class RegistrationResource extends ClientCredentialsResource { return null; } - private static boolean isLoopbackHost(String host) { + private static boolean isLoopbackHost(String host, Set<String> loopbackHosts) { if (host == null) { return false; } // Strip brackets from an IPv6 literal (e.g. [::1]). final String h = host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host; - return "localhost".equalsIgnoreCase(h) || "127.0.0.1".equals(h) || "::1".equals(h); + // Exact, case-insensitive match against the single loopback-host set (defaults + configured extras). + // No sub/parent-domain widening: only hosts explicitly listed get the plain-HTTP exception. + return loopbackHosts.contains(h.toLowerCase(Locale.ROOT)); } @Override 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 c414f6b1a..0171c9f74 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 @@ -145,44 +145,54 @@ public class TokenResource extends PasscodeTokenResourceBase { } else if (AUTH_CODE.equals(grantType)) { return handleAuthorizationCodeFlow(); } - KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(getRequestParam(CLIENT_ID)), - ResourceType.PRINCIPAL, ActionOutcome.FAILURE, - "event=token_grant grant_type=" + grantType + " reason=unsupported_grant_type"); - return error("invalid_request", "invalid grant type: " + grantType); + return super.doPost(); // with this, we don't need an additional KNOXTOKEN service in any KnoxIDF topology + } + + private boolean isAuthCodeFlow() { + return isAuthCodeFlow(getRequestParam(GRANT_TYPE)); + } + + private boolean isAuthCodeFlow(String grantType) { + return AUTH_CODE.equals(grantType); } @Override protected UserContext buildUserContext(HttpServletRequest request) { - try { - final TokenMetadata tokenMetadata = getAuthCodeMetadata(); - final String scope = tokenMetadata.getMetadata(SCOPE); - final Map<String, Object> userParams = userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope); - userParams.put(SCOPE, scope); - return new UserContext(tokenMetadata.getUserName(), null, userParams); - } catch (UnknownTokenException e) { - //this should not happen as we have just validated the auth code - throw new RuntimeException(e); + if (isAuthCodeFlow()) { + try { + final TokenMetadata tokenMetadata = getAuthCodeMetadata(); + final String scope = tokenMetadata.getMetadata(SCOPE); + final Map<String, Object> userParams = userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope); + userParams.put(SCOPE, scope); + return new UserContext(tokenMetadata.getUserName(), null, userParams); + } catch (UnknownTokenException e) { + //this should not happen as we have just validated the auth code + throw new RuntimeException(e); + } } + return super.buildUserContext(request); } @Override protected void addArbitraryTokenMetadata(TokenMetadata tokenMetadata) { - try { - super.addArbitraryTokenMetadata(tokenMetadata); - final String code = getRequestParam(CODE); - if (StringUtils.isNotBlank(code)) { - final TokenMetadata authCodeTokenMetadata = getAuthCodeMetadata(); - - //if the auth code token was a result of a federated OIDC call, we need to save the associated - //federated identity ID in the JWT too (so that it can be looked up while fetching user info) - final String federatedIdentityId = authCodeTokenMetadata.getMetadata(FEDERATED_IDENTITY_ID); - if (StringUtils.isNotBlank(federatedIdentityId)) { - tokenMetadata.add(FEDERATED_IDENTITY_ID, federatedIdentityId); + super.addArbitraryTokenMetadata(tokenMetadata); + if (isAuthCodeFlow()) { + try { + final String code = getRequestParam(CODE); + if (StringUtils.isNotBlank(code)) { + final TokenMetadata authCodeTokenMetadata = getAuthCodeMetadata(); + + //if the auth code token was a result of a federated OIDC call, we need to save the associated + //federated identity ID in the JWT too (so that it can be looked up while fetching user info) + final String federatedIdentityId = authCodeTokenMetadata.getMetadata(FEDERATED_IDENTITY_ID); + if (StringUtils.isNotBlank(federatedIdentityId)) { + tokenMetadata.add(FEDERATED_IDENTITY_ID, federatedIdentityId); + } } + } catch (UnknownTokenException e) { + //this should not happen as we have just validated the auth code + throw new RuntimeException(e); } - } catch (UnknownTokenException e) { - //this should not happen as we have just validated the auth code - throw new RuntimeException(e); } } @@ -190,21 +200,23 @@ public class TokenResource extends PasscodeTokenResourceBase { protected ResponseMap buildResponseMap(JWT token, long expires) throws TokenServiceException { final ResponseMap responseMap = super.buildResponseMap(token, expires); - final String code = getRequestParam(CODE); - TokenMetadata authCodeTokenMetadata = null; - if (StringUtils.isNotBlank(code)) { - try { - authCodeTokenMetadata = getAuthCodeMetadata(); - } catch (UnknownTokenException e) { - //NOP + if (isAuthCodeFlow()) { + final String code = getRequestParam(CODE); + TokenMetadata authCodeTokenMetadata = null; + if (StringUtils.isNotBlank(code)) { + try { + authCodeTokenMetadata = getAuthCodeMetadata(); + } catch (UnknownTokenException e) { + //NOP + } } - } - responseMap.map.put("id_token", generateIdToken(token, authCodeTokenMetadata)); + responseMap.map.put("id_token", generateIdToken(token, authCodeTokenMetadata)); - final String refreshToken = generateRefreshToken(token); - if (StringUtils.isNotBlank(refreshToken)) { - responseMap.map.put(REFRESH_TOKEN, refreshToken); + final String refreshToken = generateRefreshToken(token); + if (StringUtils.isNotBlank(refreshToken)) { + responseMap.map.put(REFRESH_TOKEN, refreshToken); + } } return responseMap; diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java index db91f336c..35dd4b4f1 100644 --- a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java @@ -16,16 +16,17 @@ */ package org.apache.knox.gateway.service.knoxidf; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import org.junit.Test; +import javax.ws.rs.core.Response; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Set; -import javax.ws.rs.core.Response; - -import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; /** * Verifies the dynamic-registration redirect-URI policy: HTTPS is required (RFC 8252), plain HTTP is @@ -34,7 +35,7 @@ import org.junit.Test; public class RegistrationRedirectUriPolicyTest { private static Response verify(String... uris) { - return RegistrationResource.verifyRedirectUris(java.util.Arrays.asList(uris)); + return RegistrationResource.verifyRedirectUris(Arrays.asList(uris)); } @Test @@ -73,4 +74,46 @@ public class RegistrationRedirectUriPolicyTest { public void testOneBadUriAmongGoodOnesRejectsWhole() { assertEquals(400, verify("https://good.example.com/cb", "http://evil.example.com/cb").getStatus()); } + + @Test + public void testConfiguredLoopbackHostAllowsPlainHttp() { + final Set<String> hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + assertNull("A configured loopback host must be allowed over plain HTTP.", + RegistrationResource.verifyRedirectUris(Collections.singletonList("http://host.docker.internal:8443/cb"), hosts)); + } + + @Test + public void testConfiguredLoopbackMatchIsCaseInsensitive() { + final Set<String> hosts = RegistrationResource.parseLoopbackHosts("Host.Docker.Internal"); + assertNull(RegistrationResource.verifyRedirectUris(Collections.singletonList("http://HOST.docker.internal/cb"), hosts)); + } + + @Test + public void testHostNotConfiguredStillRejected() { + final Set<String> hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + assertEquals("A host outside the allowlist must still require HTTPS.", 400, + RegistrationResource.verifyRedirectUris(Collections.singletonList("http://evil.docker.internal/cb"), hosts).getStatus()); + } + + @Test + public void testConfiguredLoopbackDoesNotWidenToSubdomains() { + final Set<String> hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + // Exact match only: a sub-domain of an allowlisted host is NOT itself allowlisted. + assertEquals(400, RegistrationResource.verifyRedirectUris(Collections.singletonList("http://evil.host.docker.internal/cb"), hosts).getStatus()); + } + + @Test + public void testDefaultsAlwaysPresentAlongsideConfiguredHosts() { + final Set<String> hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + // The three hard-coded loopback hosts survive even when extras are configured. + assertNull(RegistrationResource.verifyRedirectUris(Collections.singletonList("http://localhost:8080/cb"), hosts)); + assertNull(RegistrationResource.verifyRedirectUris(Collections.singletonList("http://127.0.0.1/cb"), hosts)); + } + + @Test + public void testBlankConfigYieldsDefaultsOnly() { + assertEquals(RegistrationResource.DEFAULT_LOOPBACK_HOSTS, RegistrationResource.parseLoopbackHosts(null)); + assertEquals(RegistrationResource.DEFAULT_LOOPBACK_HOSTS, RegistrationResource.parseLoopbackHosts(" ")); + assertEquals(RegistrationResource.DEFAULT_LOOPBACK_HOSTS, RegistrationResource.parseLoopbackHosts(" , ,")); + } } diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java index 5541d2691..192694540 100644 --- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java +++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java @@ -46,7 +46,7 @@ import javax.ws.rs.core.UriInfo; @Path(TokenResourceV2.RESOURCE_PATH) public class TokenResourceV2 extends TokenResource { - static final String RESOURCE_PATH = "knoxtoken/api/v2/token"; + public static final String RESOURCE_PATH = "knoxtoken/api/v2/token"; // REST endpoints with the same HTTP method 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 8cad77b0b..891b53992 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 @@ -65,6 +65,13 @@ public interface KnoxIDFConstants { // registration must explicitly set this to true (see the sample knoxidf topologies). String CLIENT_REGISTRATION_ANONYMOUS_ALLOWED = "knoxidf.client.registration.anonymous.allowed"; + // Comma-separated hostnames that, in addition to the hard-coded loopback set (localhost/127.0.0.1/::1), + // are permitted to use a plain-HTTP redirect_uri during dynamic client registration. Intended for + // dev setups where the callback host is not literally loopback but is equally trusted (e.g. + // 'host.docker.internal'). SECURITY: plain HTTP redirects to these hosts traverse a (virtual) + // network, so only add hosts you fully control. Empty/undefined => today's behavior (loopback only). + String CLIENT_REGISTRATION_CUSTOM_LOOPBACK_HOSTS = "knoxidf.custom.loopback.hosts"; + // 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/knox-site/docs/assets/images/knoxidf/polaris_console_home.png b/knox-site/docs/assets/images/knoxidf/polaris_console_home.png new file mode 100644 index 000000000..61d5501c5 Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/polaris_console_home.png differ diff --git a/knox-site/docs/assets/images/knoxidf/polaris_console_login.png b/knox-site/docs/assets/images/knoxidf/polaris_console_login.png new file mode 100644 index 000000000..17084f379 Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/polaris_console_login.png differ diff --git a/knox-site/docs/knoxidf/index.md b/knox-site/docs/knoxidf/index.md index 3eac8e1b5..4489b9b95 100644 --- a/knox-site/docs/knoxidf/index.md +++ b/knox-site/docs/knoxidf/index.md @@ -81,6 +81,7 @@ token. This keeps KnoxIDF modular and composable with the rest of Knox. - **[Security](security.md)** — client authentication, PKCE, consent, redirect-URI validation, and secret handling. - **[Federation](federation.md)** — brokering login to external OIDC Providers. - **[Operations](operations.md)** — high availability, rate limiting, signing-key rotation, and auditing. +- **[Integrations](integrations/polaris.md)** — worked examples of downstream services trusting KnoxIDF (e.g. replacing Keycloak in Apache Polaris). !!! note "Relationship to KIP-18" KnoxIDF was originally proposed and prototyped in diff --git a/knox-site/docs/knoxidf/integrations/polaris.md b/knox-site/docs/knoxidf/integrations/polaris.md new file mode 100644 index 000000000..439cd79a5 --- /dev/null +++ b/knox-site/docs/knoxidf/integrations/polaris.md @@ -0,0 +1,533 @@ +<!-- + 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 + + https://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. +--> + +# Apache Polaris (Client Credentials) + +[Apache Polaris](https://polaris.apache.org/) is a catalog for Apache Iceberg. Its +getting-started stack ships with a [Keycloak](https://www.keycloak.org/) integration that shows +Polaris trusting an **external** OpenID Connect Provider for machine-to-machine access. Because +KnoxIDF is a standard OIDC Provider, it can take Keycloak's place: Polaris trusts Knox exactly as +it would trust Keycloak, and clients obtain access tokens from Knox using the **Client +Credentials** grant. + +This page walks through swapping Keycloak for KnoxIDF in Polaris' getting-started environment and +verifying end to end that Knox-issued tokens are accepted by the realms Polaris configures to +trust an external IdP. + +!!! info "What this validates" + That a downstream service configured for a Keycloak-style OIDC provider works, unchanged in + concept, against KnoxIDF — the client credentials flow, JWKS-based signature verification, and + claim-to-role/principal mapping. + +## How it fits together + +Polaris' getting-started stack defines three realms, each with a different authentication mode: + +| Realm | `polaris.authentication` type | Who issues the accepted token | +|-------|-------------------------------|-------------------------------| +| `realm-internal` | `internal` | Polaris' own token endpoint (`root:s3cr3t`). A Knox token is **rejected**. | +| `realm-external` | `external` | The external OIDC Provider only — here, **KnoxIDF**. | +| `realm-mixed` | `mixed` | Either Polaris **or** the external OIDC Provider (**KnoxIDF**). | + +Polaris is pointed at KnoxIDF via Quarkus OIDC. It fetches KnoxIDF's discovery document and JWKS, +validates the token signature and `iss`, and maps claims to a Polaris principal and roles: + +```mermaid +sequenceDiagram + participant C as Client + participant K as KnoxIDF (knoxidf-token) + participant P as Polaris (realm-external / realm-mixed) + C->>K: POST /token (grant_type=client_credentials, client_id, client_secret) + K-->>C: Knox-signed access token (JWT) + C->>P: GET /api/management/v1/catalogs (Authorization: Bearer <token>, Polaris-Realm: realm-external) + P->>K: GET /.well-known/openid-configuration, /jwks (once, cached) + P->>P: verify signature + iss, map principal_id / principal_name / principal_roles + P-->>C: 200 OK +``` + +## Prerequisites + +- **Docker** — to run the Polaris getting-started stack. +- **A running Knox with KnoxIDF**, reachable from the Polaris container at + `https://host.docker.internal:8443`. If you have not built and started Knox yet, follow + [Getting Started](../getting_started.md) first. +- **Polaris source** — `git clone https://github.com/apache/polaris.git` (this guide assumes it is + cloned at `~/projects/polaris`). + +!!! note "host.docker.internal" + The Polaris container reaches the Knox process running on your host through + `host.docker.internal`. On Linux, add + `--add-host=host.docker.internal:host-gateway` (or the Compose `extra_hosts` equivalent) if + your Docker version does not resolve it automatically. + +## 1. Deploy the `knoxidf-token` topology + +For this integration you only need a **single** topology — `knoxidf-token` — fronted by Knox's +`JWTProvider`. Save the following as `$KNOX_HOME/conf/topologies/knoxidf-token.xml`: + +```xml +<?xml version="1.0" encoding="utf-8"?> +<topology> + <gateway> + <provider> + <role>federation</role> + <name>JWTProvider</name> + <enabled>true</enabled> + <param> + <name>knox.token.exp.server-managed</name> + <value>true</value> + </param> + <param> + <name>jwt.expected.issuer</name> + <value>https://host.docker.internal:8443/gateway/knoxidf-sso/knoxidf, https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf</value> + </param> + <param> + <name>jwt.unauthenticated.path.list</name> + <value>/knoxidf/api/v1/.well-known/openid-configuration,/knoxidf/api/v1/jwks</value> + </param> + </provider> + </gateway> + + <service> + <role>KNOXIDF</role> + <param> + <name>knoxidf.knox.token.ttl</name> + <value>120000</value> <!-- 2 mins --> + </param> + <param> + <name>knoxidf.knox.token.issuer</name> + <value>https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf</value> + </param> + <param> + <name>knoxidf.knox.token.limit.per.user</name> + <value>-1</value> + </param> + <param> + <name>knoxidf.knox.token.hardcoded.claim.mappings</name> + <value>principal_roles=admin;scope=openid;principal_id=0;principal_name=root</value> + </param> + </service> +</topology> +``` + +A few parameters are load-bearing for Polaris: + +| Parameter | Why Polaris needs it | +|-----------|----------------------| +| `jwt.unauthenticated.path.list` | Lets Polaris reach `/.well-known/openid-configuration` and `/jwks` **without** a bearer token, so it can bootstrap discovery and signature verification. | +| `jwt.expected.issuer` | Must contain the same issuer string Knox stamps into the token (see below), so the `JWTProvider` accepts KnoxIDF's own tokens on this topology. | +| `knoxidf.knox.token.issuer` | Sets the `iss` claim to the topology's own URL. Polaris' `quarkus.oidc.auth-server-url` resolves discovery from this issuer, so the two must agree. | +| `knoxidf.knox.token.hardcoded.claim.mappings` | **Required.** Polaris resolves a principal and its roles from token claims. Without these claims Polaris rejects the token even though the signature is valid. | + +!!! warning "The hard-coded claim mappings are mandatory for Polaris" + `principal_roles=admin;scope=openid;principal_id=0;principal_name=root` injects exactly the + claims Polaris' OIDC mapping reads: + + | Claim | Polaris config that consumes it | + |-------|---------------------------------| + | `principal_roles` | `quarkus.oidc.roles.role-claim-path=principal_roles` | + | `principal_id` | `polaris.oidc.principal-mapper.id-claim-path=principal_id` | + | `principal_name` | `polaris.oidc.principal-mapper.name-claim-path=principal_name` | + | `scope` | Standard OAuth scope claim (`openid`). | + + `principal_id=0` / `principal_name=root` map the client onto Polaris' bootstrap `root` + principal. Adjust these to match a real Polaris principal for anything beyond a smoke test. See + the [Configuration Reference](../configuration.md#hard-coded-claim-mappings) for the underlying + `knox.token.hardcoded.claim.mappings` parameter. + +Knox hot-deploys the topology within a few seconds. Confirm discovery is reachable: + +```bash +curl -sk https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/.well-known/openid-configuration | jq . +``` + +## 2. Register a client for the Client Credentials flow + +Register a confidential client and keep its `client_id` / `client_secret` — Polaris and its setup +scripts authenticate with them. (See [Getting Started §5](../getting_started.md#5-register-a-client) +for details; if your registration endpoint is not open anonymously, register through whichever +front topology authenticates you.) + +```bash +curl -sk -X POST \ + https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/client/register \ + -H 'Content-Type: application/json' \ + -d '{ + "client_name": "polaris", + "grant_types": ["client_credentials"] + }' | jq . +``` + +Confirm the credentials mint a token before wiring up Polaris: + +```bash +curl -sk -X POST \ + https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=<client_id>' \ + -d 'client_secret=<client_secret>' | jq -r .access_token +``` + +Decode the resulting JWT (e.g. at [jwt.io](https://jwt.io) or with `jq`) and verify it carries +`iss`, `principal_roles`, `principal_id`, and `principal_name`. + +## 3. Create the Polaris environment + +Polaris' getting-started tree keeps one directory per IdP under `getting-started/`. Create a +KnoxIDF variant alongside the Keycloak one: + +```bash +cd ~/projects/polaris/getting-started +cp -r keycloak polaris_knoxidf # start from the Keycloak template +``` + +Then edit `polaris_knoxidf/docker-compose.yml` to point Polaris at KnoxIDF and **remove the +Keycloak service** — Knox now plays that role. The result looks like this: + +```yaml +services: + + polaris: + image: apache/polaris:latest + ports: + - "8181:8181" # API + - "8182:8182" # management (metrics + health) + - "5005:5005" # optional debugger + environment: + POLARIS_BOOTSTRAP_CREDENTIALS: realm-internal,root,s3cr3t;realm-external,root,s3cr3t;realm-mixed,root,s3cr3t + polaris.realm-context.realms: realm-internal,realm-external,realm-mixed + polaris.authentication.type: internal + polaris.authentication."realm-external".type: external + polaris.authentication."realm-mixed".type: mixed + quarkus.oidc.tenant-enabled: true + + # --- Trust KnoxIDF as the external OIDC Provider --- + quarkus.oidc.auth-server-url: https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf/api/v1 + quarkus.oidc.client-id: <client_id> + quarkus.oidc.roles.role-claim-path: principal_roles + polaris.oidc.principal-mapper.id-claim-path: principal_id + polaris.oidc.principal-mapper.name-claim-path: principal_name + + # --- Accept Knox's self-signed dev certificate (dev only) --- + quarkus.tls.trust-all: "true" + quarkus.oidc.tls.tls-configuration-name: "" + quarkus.oidc.tls.verification: none + + polaris.features."ALLOW_INSECURE_STORAGE_TYPES": "true" + polaris.features."SUPPORTED_CATALOG_STORAGE_TYPES": "[\"FILE\",\"S3\",\"GCS\",\"AZURE\"]" + polaris.readiness.ignore-severe-issues: "true" + healthcheck: + test: ["CMD", "curl", "http://localhost:8182/q/health"] + interval: 2s + timeout: 10s + retries: 10 + start_period: 10s + + polaris-setup: + image: alpine/curl + depends_on: + polaris: + condition: service_healthy + environment: + - CLIENT_ID=root + - CLIENT_SECRET=s3cr3t + volumes: + - ../assets/polaris/:/polaris + entrypoint: "/bin/sh" + command: + - "-c" + - >- + apk add --no-cache jq && + chmod +x /polaris/create-catalog.sh && + token=$$(curl -sk -X POST -H "Content-Type: application/x-www-form-urlencoded" 'https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf/api/v1/token' -d 'client_id=<client_id>' -d 'client_secret=<client_secret>' -d 'grant_type=client_credentials' | jq -r .access_token) && + /polaris/create-catalog.sh realm-internal && + /polaris/create-catalog.sh realm-external $$token && + /polaris/create-catalog.sh realm-mixed $$token +``` + +The key changes relative to the Keycloak template: + +- **`quarkus.oidc.auth-server-url`** points at the `knoxidf-token` topology's OIDC base + (`…/knoxidf/api/v1`) instead of Keycloak. This is the issuer Polaris uses for discovery, so it + must match `knoxidf.knox.token.issuer` from the topology. +- **`quarkus.oidc.client-id`** is your registered KnoxIDF `client_id`. +- **`quarkus.oidc.roles.role-claim-path`** / **`principal-mapper.*-claim-path`** read the + `principal_*` claims injected by the topology's hard-coded claim mappings. +- **`quarkus.tls.trust-all` / `quarkus.oidc.tls.verification: none`** let Quarkus accept Knox's + self-signed development certificate. **Development only** — provide a real trust store in + production. +- The **`polaris-setup`** helper fetches a KnoxIDF token via client credentials and uses it to + create a catalog in the `realm-external` and `realm-mixed` realms (which trust Knox), while + `realm-internal` is seeded with Polaris' own `root:s3cr3t` credentials. + +!!! danger "Never commit real secrets" + Replace `<client_id>` / `<client_secret>` with your registered values. `client_secret` is a + credential — keep it out of version control. + +## 4. Run it + +```bash +cd ~/projects/polaris +docker compose -f getting-started/polaris_knoxidf/docker-compose.yml up +``` + +Polaris comes up on `http://localhost:8181` (management on `8182`). The `polaris-setup` container +runs once, obtains a KnoxIDF token, and creates the `quickstart_catalog` in each realm. + +## 5. Verify the flow + +The verification calls the Polaris management API (`/api/management/v1/catalogs`) with different +tokens and `Polaris-Realm` headers, and asserts that each combination returns the HTTP status the +realm's authentication mode dictates. The script below automates the whole matrix: it mints a +KnoxIDF token via client credentials, mints Polaris-native tokens for the `internal` and `mixed` +realms (using `root:s3cr3t` against Polaris' own `/api/catalog/v1/oauth/tokens`), then exercises +every `(token, realm)` pair. + +Save it as `polaris_knoxidf_test.sh` and fill in your registered `client_id` / `client_secret`: + +??? example "polaris_knoxidf_test.sh" + ```bash + #!/usr/bin/env bash + set -euo pipefail + + ############################################################################### + # CONFIG + ############################################################################### + + POLARIS_URL="http://localhost:8181" + KNOX_TOKEN_URL="https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/token" + + CLIENT_ID="<client_id>" + CLIENT_SECRET="<client_secret>" + + ############################################################################### + # 1. OBTAIN KNOXIDF TOKEN (client credentials) + ############################################################################### + + echo "" + echo "==================================================================" + echo " OBTAINING TOKEN FROM KNOXIDF" + echo "==================================================================" + + KNOX_TOKEN=$(curl -sk \ + -X POST "$KNOX_TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "grant_type=client_credentials" \ + | jq -r '.access_token') + + echo "KnoxIDF token: $KNOX_TOKEN" + echo "" + + ############################################################################### + # 2. OBTAIN POLARIS-NATIVE TOKENS (internal + mixed realms) + ############################################################################### + + echo "" + echo "==================================================================" + echo " OBTAINING POLARIS TOKENS (Internal + Mixed)" + echo "==================================================================" + + POLARIS_TOKEN_REALM_INTERNAL=$(curl -s "$POLARIS_URL/api/catalog/v1/oauth/tokens" \ + --user root:s3cr3t \ + -H 'Polaris-Realm: realm-internal' \ + -d 'grant_type=client_credentials' \ + -d 'scope=PRINCIPAL_ROLE:ALL' | jq -r .access_token) + + POLARIS_TOKEN_REALM_MIXED=$(curl -s "$POLARIS_URL/api/catalog/v1/oauth/tokens" \ + --user root:s3cr3t \ + -H 'Polaris-Realm: realm-mixed' \ + -d 'grant_type=client_credentials' \ + -d 'scope=PRINCIPAL_ROLE:ALL' | jq -r .access_token) + + echo "Polaris token (realm-internal): $POLARIS_TOKEN_REALM_INTERNAL" + echo "" + echo "Polaris token (realm-mixed) : $POLARIS_TOKEN_REALM_MIXED" + echo "" + + ############################################################################### + # 3. TEST CASES + ############################################################################### + + function test_curl() { + local token="$1" + local realm="$2" + local description="$3" + local expected="$4" # Expected outcome: "SUCCEED" or "FAIL" + + echo "" + echo "==================================================================" + echo " $description" + echo "==================================================================" + + local response status body + response=$(curl -sk -w "%{http_code}" \ + -H "Authorization: Bearer $token" \ + -H "Polaris-Realm: $realm" \ + -H "Accept: application/json" \ + "$POLARIS_URL/api/management/v1/catalogs") + + status="${response: -3}" # last 3 characters = HTTP code + body="${response:0:${#response}-3}" + + local expected_code + if [[ "$expected" == "SUCCEED" ]]; then + expected_code=200 + else + expected_code=401 + fi + + if [ "$status" -eq "$expected_code" ]; then + echo "✅ PASS: Got HTTP $status as expected" + if [ "$status" -eq 200 ]; then + echo "Response JSON:" + echo "$body" | jq . + fi + else + echo "❌ FAIL: Got HTTP $status, expected $expected_code" + fi + } + + # External Knox token + test_curl "$KNOX_TOKEN" "realm-internal" "TEST: Knox token → realm-internal (SHOULD FAIL)" FAIL + test_curl "$KNOX_TOKEN" "realm-external" "TEST: Knox token → realm-external (SHOULD SUCCEED)" SUCCEED + test_curl "$KNOX_TOKEN" "realm-mixed" "TEST: Knox token → realm-mixed (SHOULD SUCCEED)" SUCCEED + + # Polaris-native tokens + test_curl "$POLARIS_TOKEN_REALM_INTERNAL" "realm-internal" "TEST: Polaris token (internal) → realm-internal (SHOULD SUCCEED)" SUCCEED + test_curl "$POLARIS_TOKEN_REALM_MIXED" "realm-mixed" "TEST: Polaris token (mixed) → realm-mixed (SHOULD SUCCEED)" SUCCEED + + # Cross-realm failure + test_curl "$POLARIS_TOKEN_REALM_INTERNAL" "realm-mixed" "TEST: Polaris token (internal) → realm-mixed (SHOULD FAIL)" FAIL + + echo "" + echo "==================================================================" + echo "ALL TESTS COMPLETE" + echo "==================================================================" + ``` + +Run it once the stack is up: + +```bash +chmod +x polaris_knoxidf_test.sh +./polaris_knoxidf_test.sh +``` + +### Expected results + +The script asserts these six `(token, realm)` combinations: + +| # | Token source | `Polaris-Realm` | Expected | +|---|--------------|-----------------|----------| +| 1 | KnoxIDF (client credentials) | `realm-internal` | **401** — internal realm rejects external tokens | +| 2 | KnoxIDF (client credentials) | `realm-external` | **200** — external realm trusts KnoxIDF | +| 3 | KnoxIDF (client credentials) | `realm-mixed` | **200** — mixed realm accepts KnoxIDF | +| 4 | Polaris-native (internal) | `realm-internal` | **200** | +| 5 | Polaris-native (mixed) | `realm-mixed` | **200** | +| 6 | Polaris-native (internal) | `realm-mixed` | **401** — token minted for another realm | + +A sample run (token values and JSON bodies trimmed): + +??? success "Sample output" + ```text + ================================================================== + OBTAINING TOKEN FROM KNOXIDF + ================================================================== + KnoxIDF token: eyJqa3UiOiJodHRwczovL2xvY2FsaG9zdDo4NDQz... + + ================================================================== + OBTAINING POLARIS TOKENS (Internal + Mixed) + ================================================================== + Polaris token (realm-internal): eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... + Polaris token (realm-mixed) : eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... + + ================================================================== + TEST: Knox token → realm-internal (SHOULD FAIL) + ================================================================== + ✅ PASS: Got HTTP 401 as expected + + ================================================================== + TEST: Knox token → realm-external (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + Response JSON: + { + "catalogs": [ + { + "type": "INTERNAL", + "name": "quickstart_catalog", + "properties": { "default-base-location": "file:///var/tmp/quickstart_catalog/" }, + "storageConfigInfo": { + "storageType": "FILE", + "allowedLocations": [ "file:///var/tmp/quickstart_catalog/" ] + } + } + ] + } + + ================================================================== + TEST: Knox token → realm-mixed (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + + ================================================================== + TEST: Polaris token (internal) → realm-internal (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + + ================================================================== + TEST: Polaris token (mixed) → realm-mixed (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + + ================================================================== + TEST: Polaris token (internal) → realm-mixed (SHOULD FAIL) + ================================================================== + ✅ PASS: Got HTTP 401 as expected + + ================================================================== + ALL TESTS COMPLETE + ================================================================== + ``` + +Cases 2 and 3 are the ones that matter: a `200` from `realm-external` (and `realm-mixed`) with a +**KnoxIDF-issued** token confirms KnoxIDF has fully replaced Keycloak for the client credentials +flow — Polaris fetched discovery and JWKS from Knox, verified the signature and issuer, and mapped +the `principal_*` claims onto a Polaris principal and role. Case 1 confirms the `internal` realm +still refuses external tokens, and case 6 confirms realm isolation. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| `401` from `realm-external` with a valid-looking token | `iss` in the token does not match `quarkus.oidc.auth-server-url`. Align `knoxidf.knox.token.issuer` with the URL Polaris uses. | +| Polaris logs "unable to resolve principal" / role errors | `principal_id` / `principal_name` / `principal_roles` claims missing. Check `knoxidf.knox.token.hardcoded.claim.mappings` on the topology. | +| Polaris cannot fetch discovery/JWKS (connection or `401` at startup) | Discovery/JWKS not anonymous. Ensure `jwt.unauthenticated.path.list` lists both `/.well-known/openid-configuration` and `/jwks`, and that `host.docker.internal:8443` is reachable from the container. | +| TLS handshake failures | Knox's dev certificate is not trusted. For local testing set `quarkus.tls.trust-all: "true"`; in production configure a proper trust store. | +| Token expires mid-test | `knoxidf.knox.token.ttl` is `120000` (2 min) in the sample topology. Raise it or re-request the token. | + +## Next steps + +- Drive an interactive browser login (Authorization Code + PKCE) instead of client credentials — + see the [Endpoint Reference](../endpoints.md#authorization-endpoint) and + [Security](../security.md#pkce). +- Broker Polaris logins to an upstream OIDC Provider while still issuing Knox tokens — see + [Federation](../federation.md). +- Tune token lifetime, issuer, and claims — see the + [Configuration Reference](../configuration.md). diff --git a/knox-site/docs/knoxidf/integrations/polaris_console.md b/knox-site/docs/knoxidf/integrations/polaris_console.md new file mode 100644 index 000000000..7c83c2e69 --- /dev/null +++ b/knox-site/docs/knoxidf/integrations/polaris_console.md @@ -0,0 +1,471 @@ +<!-- + 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 + + https://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. +--> + +# Apache Polaris Console (Authorization Code + PKCE) + +The [Apache Polaris](https://polaris.apache.org/) **Console** is a browser single-page +application (SPA) — a React/Vite app that talks to the Polaris REST API. Unlike the +[Client Credentials integration](polaris.md), which is machine-to-machine, the Console signs in a +**human**: it runs the OAuth2 **Authorization Code + PKCE** flow against KnoxIDF, obtains a +Knox-issued access token in the browser, and then calls the Polaris API with that token. + +This page wires KnoxIDF up as the Console's OpenID Connect Provider end to end. It covers the two +KnoxIDF topologies involved, the KnoxSSO topology that authenticates the user, registering the +Console as a **public** (secret-less) PKCE client, the Polaris backend configuration, the +Console's `.env`, and — because this flow crosses three origins in the browser — the CORS wiring +that makes it all work. + +!!! info "What this validates" + That a browser SPA can authenticate an interactive user through KnoxIDF using Authorization + Code + PKCE with **no client secret**, that the resulting Knox token is accepted by the + Polaris API, and that the three cross-origin surfaces (discovery, token, and the Polaris API) + are reachable from the SPA. + +!!! note "Public client, no secret" + A browser SPA cannot keep a secret. This flow therefore uses a **public** client + (`token_endpoint_auth_method` = `none`) protected by **PKCE** (`S256`). There is no + `client_secret` anywhere in the browser, the `.env`, or the token request — the proof of + possession is the PKCE `code_verifier`. + +## How it fits together + +Three server-side pieces cooperate, plus the Polaris backend: + +| Component | Topology | Role in this flow | +|-----------|----------|-------------------| +| **KnoxSSO** | `knoxsso` | Authenticates the human (LDAP/Shiro) and mints the `hadoop-jwt` SSO cookie. | +| **KnoxIDF front** | `knoxidf-sso` | Serves discovery, `/authorize`, and the callback. Browser-facing, protected by the SSO cookie. Issues the authorization **code**. | +| **KnoxIDF token** | `knoxidf-token` | Serves `/token`. Exchanges the code (+ PKCE verifier) for the access token. This is the `iss` Polaris trusts. | +| **Polaris** | — (Quarkus) | Trusts KnoxIDF via Quarkus OIDC; serves `/api/management` and `/api/catalog` to the Console. | + +The Console fetches discovery from `knoxidf-sso`; the discovery document advertises the +`authorization_endpoint` on `knoxidf-sso` and the `token_endpoint` on `knoxidf-token` (the split +comes from `knoxidf.token.exchange.topology.name`). The user logs in once via KnoxSSO; the +`hadoop-jwt` cookie then lets `/authorize` issue a code without a second login. + +```mermaid +sequenceDiagram + autonumber + participant B as Browser (Console SPA, :5173) + participant SSO as knoxidf-sso (/authorize, discovery) + participant KS as knoxsso (LDAP login) + participant TOK as knoxidf-token (/token) + participant P as Polaris API (:8181) + + B->>SSO: GET /.well-known/openid-configuration (XHR) + Note over B,SSO: CORS surface #1 + SSO-->>B: authorization_endpoint (sso), token_endpoint (token) + B->>SSO: top-level redirect to /authorize?...&code_challenge=...&code_challenge_method=S256 + SSO->>KS: no hadoop-jwt cookie → redirect to KnoxSSO login + KS-->>B: login form + B->>KS: username / password + KS-->>B: set hadoop-jwt cookie, redirect back to /authorize + B->>SSO: GET /authorize (now authenticated) + SSO-->>B: 302 to redirect_uri?code=... (http://localhost:5173/auth/callback) + B->>TOK: POST /token (code, code_verifier, client_id, redirect_uri) (XHR) + Note over B,TOK: CORS surface #2 — no client_secret + TOK-->>B: access_token (Knox-signed JWT) + B->>P: GET /api/management/v1/catalogs (Bearer token, Polaris-Realm) (XHR) + Note over B,P: CORS surface #3 + P->>TOK: fetch discovery + JWKS (server-side, cached) + P-->>B: 200 OK +``` + +!!! danger "Three CORS surfaces" + Because the SPA and the servers are on different origins, **three** cross-origin surfaces must + each return CORS headers. Miss any one and the browser blocks the request with a CORS error — + even though a `curl` from the shell succeeds. + + | # | Request | Server that must send CORS | + |---|---------|----------------------------| + | 1 | `GET /.well-known/openid-configuration` (discovery) | `knoxidf-sso` topology | + | 2 | `POST /token` | `knoxidf-token` topology | + | 3 | `GET/POST /api/**` (catalogs, principals, …) | Polaris (Quarkus) | + + `/authorize` is a **top-level browser navigation**, not an XHR, so it needs **no** CORS. + +## Prerequisites + +- A working KnoxIDF deployment (see [Getting Started](../getting_started.md)). +- A KnoxSSO topology able to authenticate a user (this page uses the demo LDAP on + `ldap://localhost:33389`). +- The Polaris getting-started stack from the [Client Credentials page](polaris.md) — this page + adds the Console and the browser flow on top of it. +- The [Polaris Console](https://github.com/apache/polaris-tools) checked out and its dev server + runnable (`npm run dev`, Vite on `http://localhost:5173`). + +!!! note "Hostnames in this guide" + Browser-facing Knox URLs use `https://localhost:8443`. The Polaris **container** reaches Knox + at `https://host.docker.internal:8443` (the two differ only because one caller is your host + browser and the other is a container). Whatever host you pick for the browser side, use the + **exact same host and scheme** everywhere it appears (see the redirect-loop warning below). + +## 1. KnoxSSO topology (authenticates the user) + +The Console flow reuses your existing `knoxsso` topology to log the user in and mint the +`hadoop-jwt` cookie. Only one setting matters for this integration — the **issuer** — and it must +match `knoxidf-sso` exactly. + +```xml +<service> + <role>KNOXSSO</role> + <param> + <name>knoxsso.token.ttl</name> + <value>86400000</value> + </param> + <param> + <name>knoxsso.redirect.whitelist.regex</name> + <value>^.*$</value> + </param> + <param> + <name>knoxsso.cookie.samesite</name> + <value>Lax</value> + </param> + <param> + <!-- MUST be identical (scheme + host + port + path) to knoxidf-sso's jwt.expected.issuer --> + <name>knoxsso.token.issuer</name> + <value>https://localhost:8443/gateway/knoxidf-sso/knoxidf</value> + </param> +</service> +``` + +!!! danger "Issuer mismatch → infinite redirect loop" + If `knoxsso.token.issuer` and `knoxidf-sso`'s `jwt.expected.issuer` disagree — even only by + scheme (`http` vs `https`) — the SSO cookie `knoxidf-sso` receives is rejected, so it bounces + the browser back to KnoxSSO, which mints another cookie, and so on. Chrome shows + `ERR_TOO_MANY_REDIRECTS`. Make the two strings **byte-for-byte identical**, and clear any + stale `hadoop-jwt` cookie after changing them. + +## 2. `knoxidf-sso` topology (discovery, `/authorize`, callback) + +This browser-facing topology is protected by the `SSOCookieProvider` (so `/authorize` can rely on +the KnoxSSO login) and must serve discovery cross-origin to the SPA. Note the **CORS provider is +listed first**. + +```xml +<topology> + <gateway> + <!-- CORS surface #1: discovery is fetched by the SPA via XHR --> + <provider> + <role>webappsec</role> + <name>WebAppSec</name> + <enabled>true</enabled> + <param><name>cors.enabled</name><value>true</value></param> + <param><name>cors.allowOrigin</name><value>http://localhost:5173</value></param> + <param><name>cors.supportedMethods</name><value>GET,POST,HEAD,OPTIONS</value></param> + <param><name>cors.supportedHeaders</name><value>*</value></param> + <param><name>cors.exposedHeaders</name><value>*</value></param> + <param><name>cors.supportsCredentials</name><value>false</value></param> + </provider> + + <provider> + <role>federation</role> + <name>SSOCookieProvider</name> + <enabled>true</enabled> + <param> + <name>sso.authentication.provider.url</name> + <value>https://localhost:8443/gateway/knoxsso/api/v1/websso</value> + </param> + <param> + <!-- MUST match knoxsso.token.issuer exactly --> + <name>jwt.expected.issuer</name> + <value>https://localhost:8443/gateway/knoxidf-sso/knoxidf</value> + </param> + <param> + <!-- endpoints reachable before the SSO cookie exists --> + <name>sso.unauthenticated.path.list</name> + <value>/knoxidf/api/v1/.well-known/openid-configuration,/knoxidf/api/v1/jwks,/knoxidf/api/v1/client/register,/knoxidf/api/v1/callback,/knoxidf/api/v1/websso/federated/op</value> + </param> + </provider> + </gateway> + + <service> + <role>KNOXIDF</role> + <param> + <!-- /authorize issues a code on knoxidf-sso; /token lives on knoxidf-token --> + <name>knoxidf.token.exchange.topology.name</name> + <value>knoxidf-token</value> + </param> + <param> + <name>knoxidf.knox.token.issuer</name> + <value>https://localhost:8443/gateway/knoxidf-sso/knoxidf</value> + </param> + <param> + <!-- allow the loopback redirect_uri (http://localhost:5173/...) for local dev --> + <name>knoxidf.client.registration.anonymous.allowed</name> + <value>true</value> + </param> + </service> +</topology> +``` + +!!! note "Federated upstream OPs are optional" + `knoxidf-sso` can additionally federate to upstream OpenID Providers (Keycloak, Auth0, …) via + the `websso/federated/op` path. That is orthogonal to this Console flow — see + [Federation](../federation.md). Keep any upstream client secrets **out** of files you commit. + +## 3. `knoxidf-token` topology (the `/token` endpoint) + +This is the topology from the [Client Credentials page](polaris.md), with **one addition**: a CORS +provider so the SPA's `POST /token` (XHR) succeeds. The hardcoded claim mappings shape the token +into the principal/roles Polaris expects. + +```xml +<topology> + <gateway> + <!-- CORS surface #2: /token is called by the SPA via XHR --> + <provider> + <role>webappsec</role> + <name>WebAppSec</name> + <enabled>true</enabled> + <param><name>cors.enabled</name><value>true</value></param> + <param><name>cors.allowOrigin</name><value>http://localhost:5173</value></param> + <param><name>cors.supportedMethods</name><value>GET,POST,HEAD,OPTIONS</value></param> + <param><name>cors.supportedHeaders</name><value>*</value></param> + <param><name>cors.exposedHeaders</name><value>*</value></param> + <param><name>cors.supportsCredentials</name><value>false</value></param> + </provider> + + <provider> + <role>federation</role> + <name>JWTProvider</name> + <enabled>true</enabled> + <param><name>knox.token.exp.server-managed</name><value>true</value></param> + <param> + <name>jwt.expected.issuer</name> + <value>https://host.docker.internal:8443/gateway/knoxidf-sso/knoxidf, https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf</value> + </param> + <param> + <name>jwt.unauthenticated.path.list</name> + <value>/knoxidf/api/v1/.well-known/openid-configuration,/knoxidf/api/v1/jwks</value> + </param> + </provider> + </gateway> + + <service> + <role>KNOXIDF</role> + <param><name>knoxidf.knox.token.ttl</name><value>120000</value></param> + <param> + <name>knoxidf.knox.token.issuer</name> + <value>https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf</value> + </param> + <param><name>knoxidf.knox.token.limit.per.user</name><value>-1</value></param> + <param> + <!-- shape the token into the principal/roles Polaris maps --> + <name>knoxidf.knox.token.hardcoded.claim.mappings</name> + <value>principal_roles=admin;scope=openid;principal_id=0;principal_name=root</value> + </param> + </service> +</topology> +``` + +!!! warning "The token issuer is what Polaris trusts" + `knoxidf-token`'s `knoxidf.knox.token.issuer` is the `iss` baked into the access token. It + must equal Polaris' `quarkus.oidc.auth-server-url` base (`host.docker.internal:8443`), which + is why the browser side (`localhost`) and the Polaris side (`host.docker.internal`) differ. + +## 4. Register the Console as a public PKCE client + +Register a **public** client whose only redirect URI is the Console's callback. Because the +redirect URI is a **loopback** address, plain `http` is permitted (RFC 8252); a non-loopback +redirect URI would have to use `https`. + +```bash +curl -sk -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + 'https://localhost:8443/gateway/knoxidf-sso/knoxidf/api/v1/client/register' \ + -d 'redirect_uris=http://localhost:5173/auth/callback' \ + -d 'allowed_scopes=openid,profile,email,offline_access' +``` + +The response's `token_id` is your `client_id`. A public client has **no usable secret** — the +Console never sends one; PKCE is the client's proof of possession. + +```json +{ + "token_id": "<client_id>", + "redirect_uris": "http://localhost:5173/auth/callback", + "allowed_scopes": "openid,profile,email,offline_access" +} +``` + +!!! note "Registration uses form encoding" + `/client/register` is `application/x-www-form-urlencoded` (comma-separated `redirect_uris`), + **not** JSON. See the [Endpoint Reference](../endpoints.md#client-registration-endpoint). + +## 5. Configure Polaris (backend) + +Start from the `docker-compose.yml` in the [Client Credentials page](polaris.md) — the OIDC +settings (`quarkus.oidc.auth-server-url`, `quarkus.oidc.client-id`, the `principal-mapper` and +`role-claim-path`) are unchanged. Add the **CORS** block so the Console (a different origin) can +call `/api/**`: + +```yaml + environment: + # ... existing OIDC / realm settings from the Client Credentials guide ... + + # CORS surface #3: allow the Console SPA (Vite dev server) to call /api/**. + # Without these, the browser blocks cross-origin /api/** requests (the preflight + # of the Authorization and Polaris-Realm headers fails). + quarkus.http.cors.enabled: "true" + quarkus.http.cors.origins: "http://localhost:5173" + quarkus.http.cors.methods: "GET,POST,PUT,DELETE,PATCH,OPTIONS,HEAD" + quarkus.http.cors.headers: "Authorization,Content-Type,Accept,Origin,X-Requested-With,Polaris-Realm" + quarkus.http.cors.exposed-headers: "*" + quarkus.http.cors.access-control-max-age: "24H" + quarkus.http.cors.access-control-allow-credentials: "false" +``` + +!!! danger "It is `quarkus.http.cors.enabled`, not `quarkus.http.cors`" + On Quarkus 3.x the enable flag is **`quarkus.http.cors.enabled`**. Setting a bare + `quarkus.http.cors: "true"` logs `Unrecognized configuration key "quarkus.http.cors" ... it + will be ignored` and **no** `Access-Control-*` headers are emitted — the OPTIONS preflight + returns `200` but without CORS headers, and the browser still blocks the call. + +!!! warning "The `Polaris-Realm` header must be allowed" + The Console sends a custom `Polaris-Realm` header (e.g. `realm-external`). It must appear in + `quarkus.http.cors.headers`, or the preflight fails. + +!!! note "Recreate the container after env changes" + Environment changes only take effect on a fresh container. Re-create it, don't just restart: + `docker compose up -d --force-recreate polaris`. + +Verify the preflight actually carries CORS headers: + +```bash +curl -s -i -X OPTIONS 'http://localhost:8181/api/management/v1/catalogs' \ + -H 'Origin: http://localhost:5173' \ + -H 'Access-Control-Request-Method: GET' \ + -H 'Access-Control-Request-Headers: authorization,polaris-realm' +# Expect: access-control-allow-origin: http://localhost:5173 (and allow-methods/headers) +``` + +### Create a principal so the Console can show the signed-in user + +The Console shows the signed-in user's name in the top-right corner. It derives that name from +the token's **`sub`** claim (here `admin` — the KnoxSSO/LDAP user you log in as) and then looks it +up in Polaris' principal store via `GET /api/management/v1/principals/{sub}`. The name is shown +only if a **persisted principal with that exact name exists**; otherwise the Console falls back to +the generic label `User`. + +Polaris does **not** auto-create principals for federated logins, and the getting-started stack +bootstraps only `root` — so until a matching principal exists, the header shows `User`. Create one +whose name equals the `sub`. The `polaris-setup` container already obtains a service-admin token, +so add one more step reusing it: + +```yaml + # ... after the create-catalog.sh calls, still using the same root $token ... + curl -sk -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' -H 'Polaris-Realm: realm-external' \ + 'http://polaris:8181/api/management/v1/principals' \ + -d '{"principal":{"name":"admin"}}' +``` + +Then re-create the setup container: `docker compose up -d --force-recreate polaris-setup`. (Or run +the equivalent `POST /api/management/v1/principals` once by hand with any service-admin token.) + +!!! note "Display only — not the authorizing identity" + This principal exists purely so the Console can render a name. The API calls themselves are + still authorized by the token's `principal_*` claims (the hardcoded mapping on + `knoxidf-token`), independent of this principal. Because Polaris resolves the caller by name + only when `principal_id` is absent or `0`, and the mapping pins `principal_name=root`, the + caller remains `root` — the `admin` principal is looked up for display alone. + +## 6. Configure the Polaris Console (`.env`) + +Point the Console at the Polaris API and at KnoxIDF as its OIDC provider. Note the issuer URL uses +the **`/api/v1`** base (so discovery loads from `/api/v1/.well-known/openid-configuration`), the +redirect URI matches the one you registered, and there is **no client secret**. + +```bash +# Polaris API +VITE_POLARIS_API_URL=http://localhost:8181 +VITE_POLARIS_REALM=realm-external +VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL + +# KnoxIDF as the OIDC provider (Authorization Code + PKCE, public client) +VITE_OIDC_ISSUER_URL=https://localhost:8443/gateway/knoxidf-sso/knoxidf/api/v1 +VITE_OIDC_CLIENT_ID=<client_id> +VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback +VITE_OIDC_SCOPE=openid profile email +``` + +!!! note "Discovery `issuer` vs `VITE_OIDC_ISSUER_URL`" + `VITE_OIDC_ISSUER_URL` carries the `/api/v1` base so the SPA can find the discovery document. + The `issuer` **inside** that document is `https://localhost:8443/gateway/knoxidf-sso/knoxidf` + (no `/api/v1`). This is expected — the discovery base and the advertised issuer are allowed to + differ. + +## 7. Run it + +1. Deploy/redeploy the three topologies (`knoxsso`, `knoxidf-sso`, `knoxidf-token`). +2. `docker compose up -d --force-recreate polaris` (and the rest of the Polaris stack). +3. Start the Console dev server: `npm run dev` (serves `http://localhost:5173`). +4. Open `http://localhost:5173` and choose **Sign in with OIDC**. + +## 8. Walk through the login + +The Console's sign-in card offers a direct username/password path and, for the KnoxIDF +Authorization Code flow, **Sign in with OIDC**. The realm is set to `realm-external` and the scope +to `PRINCIPAL_ROLE:ALL`. + + + +Clicking **Sign in with OIDC** redirects the browser to `knoxidf-sso`'s `/authorize`. With no SSO +cookie yet, KnoxSSO shows its login form; after a successful LDAP login the browser returns to +`/authorize`, which issues a code and redirects to `http://localhost:5173/auth/callback?code=…`. +The Console then exchanges the code (with its PKCE `code_verifier`) at `knoxidf-token`'s `/token`, +stores the access token in memory, and lands on the dashboard. + + + +## 9. Verify + +With the token in hand, the Console calls the Polaris API. Navigating to **Catalogs** should list +the catalog created by the getting-started setup: + +```bash +# The same call the Console makes (token minted via the browser flow): +curl -s 'http://localhost:8181/api/management/v1/catalogs' \ + -H "Authorization: Bearer <access_token>" \ + -H "Polaris-Realm: realm-external" | jq . +``` + +A populated **Catalogs** page (and a `200` from the call above) confirms the full chain: +interactive login → Knox-issued token → Polaris accepting it. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `ERR_TOO_MANY_REDIRECTS` between KnoxSSO and `/authorize` | `knoxsso.token.issuer` ≠ `knoxidf-sso` `jwt.expected.issuer` (often `http` vs `https`) | Make the two issuer strings byte-for-byte identical; clear the stale `hadoop-jwt` cookie. | +| CORS error on `/.well-known/openid-configuration` | No CORS provider on `knoxidf-sso` | Add the WebAppSec CORS provider (surface #1), origin `http://localhost:5173`. | +| CORS error on `POST /token` | No CORS provider on `knoxidf-token` | Add the WebAppSec CORS provider (surface #2). | +| CORS error on `/api/management/**` or `/api/catalog/**` | Polaris (Quarkus) sends no CORS headers | Add the `quarkus.http.cors.*` block (surface #3) and re-create the container. | +| OPTIONS returns `200` but browser still blocks; log shows `Unrecognized configuration key "quarkus.http.cors"` | Wrong Quarkus key | Use `quarkus.http.cors.enabled`, not `quarkus.http.cors`. | +| Preflight fails only when the app sends `Polaris-Realm` | Header not in the allow-list | Add `Polaris-Realm` to `quarkus.http.cors.headers`. | +| `/authorize` cannot log in | KnoxSSO cannot reach its user store | Ensure the LDAP/identity store in `knoxsso` is running and reachable (`ldap://localhost:33389` in the demo). | +| `redirect_uri` rejected at `/authorize` | Callback not registered / mismatch | Register `http://localhost:5173/auth/callback` and set the identical value in `VITE_OIDC_REDIRECT_URI`. | +| Token accepted but `403` from Polaris | Claim → role/principal mapping | Check `knoxidf.knox.token.hardcoded.claim.mappings` vs Polaris' `role-claim-path` / `principal-mapper`. | +| Header shows `User` instead of the signed-in name | No persisted Polaris principal matches the token `sub` | Create a principal named after `sub` (e.g. `admin`) — see [Create a principal so the Console can show the signed-in user](#create-a-principal-so-the-console-can-show-the-signed-in-user). | + +## Next steps + +- [Client Credentials integration](polaris.md) — the machine-to-machine counterpart to this flow. +- [Endpoint Reference](../endpoints.md) — `/authorize`, `/token`, discovery, and PKCE details. +- [Federation](../federation.md) — front `knoxidf-sso` with upstream OpenID Providers. +- [Security](../security.md) — dynamic client registration and hardening. diff --git a/knox-site/mkdocs.yml b/knox-site/mkdocs.yml index c0f91bdbb..46e02e2f8 100644 --- a/knox-site/mkdocs.yml +++ b/knox-site/mkdocs.yml @@ -122,6 +122,9 @@ nav: - Security: knoxidf/security.md - Federation: knoxidf/federation.md - Operations: knoxidf/operations.md + - Integrations: + - Apache Polaris (Client Credentials): knoxidf/integrations/polaris.md + - Apache Polaris Console (Authorization Code): knoxidf/integrations/polaris_console.md - Developer Guide: - Overview: dev-guide/book.md - Extending Knox:
