You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@knox.apache.org by sm...@apache.org on 2026/08/13 16:04:06 UTC
(knox) 37/37: KNOX-3414: support Authorization Code + PKCE for public clients
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 <sm...@apache.org>
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 <no...@anthropic.com>
---
.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: