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`.
+
+![Polaris Console 
sign-in](../../assets/images/knoxidf/polaris_console_login.png)
+
+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.
+
+![Polaris Console home after 
login](../../assets/images/knoxidf/polaris_console_home.png)
+
+## 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:

Reply via email to