diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile
index 1781ead94f..5c04076611 100644
--- a/.github/workflows/build/Dockerfile
+++ b/.github/workflows/build/Dockerfile
@@ -17,7 +17,8 @@ FROM eclipse-temurin:17-jre
MAINTAINER moresandeep
-RUN useradd -ms /bin/bash gateway
+# Install dependencies
+RUN apt-get update && apt-get install -y git && useradd -ms /bin/bash gateway
# Create temporary directories for extraction
RUN mkdir -p /tmp/knox-artifacts /tmp/knoxshell-artifacts /knox-runtime /knoxshell /knox-runtime/knoxshell
@@ -43,6 +44,8 @@ ADD .github/workflows/build/conf/topologies/health.xml /knox-runtime/conf/topolo
ADD .github/workflows/build/conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml
ADD .github/workflows/build/conf/topologies/remoteauth.xml /knox-runtime/conf/topologies/remoteauth.xml
ADD .github/workflows/build/conf/topologies/k8sauth.xml /knox-runtime/conf/topologies/k8sauth.xml
+ADD .github/workflows/build/conf/topologies/knoxidf-ldap.xml /knox-runtime/conf/topologies/knoxidf-ldap.xml
+ADD .github/workflows/build/conf/topologies/knoxidf-token.xml /knox-runtime/conf/topologies/knoxidf-token.xml
RUN chown -R gateway /knox-runtime/
diff --git a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
new file mode 100644
index 0000000000..d25bb88692
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
@@ -0,0 +1,67 @@
+
+
+
+
+ authentication
+ ShiroProvider
+ true
+
+ main.ldapRealm
+ org.apache.knox.gateway.shirorealm.KnoxLdapRealm
+
+
+ main.ldapRealm.userDnTemplate
+ uid={0},ou=people,dc=hadoop,dc=apache,dc=org
+
+
+ main.ldapRealm.contextFactory.url
+ ldaps://localhost:33390
+
+
+ main.ldapRealm.contextFactory.authenticationMechanism
+ simple
+
+
+ urls./knoxidf/api/v1/.well-known/openid-configuration
+ anon
+
+
+ urls./knoxidf/api/v1/client/register
+ anon
+
+
+ urls./knoxidf/api/v1/authorize/callback
+ anon
+
+
+ urls./knoxidf/api/v1/jwks
+ anon
+
+
+ urls./**
+ authcBasic
+
+
+
+ identity-assertion
+ Default
+ true
+
+
+
+
+ KNOXIDF
+
+ knoxidf.knox.token.ttl
+ 60000
+
+
+ knoxidf.knox.token.limit.per.user
+ -1
+
+
+ token.exchange.topology.name
+ knoxidf-token
+
+
+
diff --git a/.github/workflows/build/conf/topologies/knoxidf-token.xml b/.github/workflows/build/conf/topologies/knoxidf-token.xml
new file mode 100644
index 0000000000..fdc11d62b8
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxidf-token.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ federation
+ JWTProvider
+ true
+
+ knox.token.exp.server-managed
+ true
+
+
+
+ identity-assertion
+ Default
+ true
+
+
+
+
+ KNOXIDF
+
+ knoxidf.knox.token.ttl
+ 86400000
+
+
+ knoxidf.knox.token.limit.per.user
+ -1
+
+
+
+ KNOXTOKEN
+
+ knox.token.ttl
+ 60000
+
+
+ knox.token.limit.per.user
+ -1
+
+
+
diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml
index 0f584f599c..621830115d 100644
--- a/.github/workflows/publish-test-results.yml
+++ b/.github/workflows/publish-test-results.yml
@@ -46,5 +46,5 @@ jobs:
commit: ${{ github.event.workflow_run.head_sha }}
event_file: artifacts/Event File/event.json
event_name: ${{ github.event.workflow_run.event }}
- files: "artifacts/**/*.xml"
+ files: "artifacts/test-results/**/*.xml"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index da1a7c78fc..9fbd80c413 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -153,6 +153,14 @@ jobs:
echo '===== gateway.log ====='
cat ./.github/workflows/compose/logs/gateway.log || true
+ - name: Collect Knox Logs and Conf
+ if: always()
+ run: |
+ mkdir -p .github/workflows/artifacts/knox-logs
+ mkdir -p .github/workflows/artifacts/knox-conf
+ docker compose -f ./.github/workflows/compose/docker-compose.yml cp knox:/knox-runtime/logs .github/workflows/artifacts/knox-logs
+ docker compose -f ./.github/workflows/compose/docker-compose.yml cp knox:/knox-runtime/conf .github/workflows/artifacts/knox-conf
+
- name: Upload Test Results
if: (!cancelled())
uses: actions/upload-artifact@v4
@@ -163,6 +171,28 @@ jobs:
.github/workflows/tests/test-results-single-eku.xml
.github/workflows/tests/test-results-single-eku-no-mtls.xml
+ - name: Archive Knox Logs
+ if: always()
+ run: tar -cvzf knox-logs.tar.gz -C .github/workflows/artifacts/knox-logs .
+
+ - name: Upload Knox Logs
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: knox-logs
+ path: knox-logs.tar.gz
+
+ - name: Archive Knox Conf
+ if: always()
+ run: tar -cvzf knox-conf.tar.gz -C .github/workflows/artifacts/knox-conf .
+
+ - name: Upload Knox Conf
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: knox-conf
+ path: knox-conf.tar.gz
+
- name: Upload Event File
uses: actions/upload-artifact@v4
with:
diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py
index 0a773b44e6..5d11de0533 100644
--- a/.github/workflows/tests/common_utils.py
+++ b/.github/workflows/tests/common_utils.py
@@ -17,6 +17,8 @@
from __future__ import annotations
+import base64
+import json
import os
import unittest
from typing import Any
@@ -72,3 +74,32 @@ def assert_hsts_header(testcase: unittest.TestCase, response: requests.Response)
"""Assert the response includes the expected Strict-Transport-Security header."""
testcase.assertIn(HSTS_HEADER_NAME, response.headers)
testcase.assertEqual(response.headers[HSTS_HEADER_NAME], HSTS_EXPECTED_VALUE)
+
+def get_token_id_display_text(uuid):
+ """
+ Format the token ID for display, matching Knox's getTokenIDDisplayText logic.
+ """
+ if uuid and len(uuid) == 36 and "-" in uuid:
+ first_dash = uuid.find('-')
+ last_dash = uuid.rfind('-')
+ return f"{uuid[:first_dash]}...{uuid[last_dash+1:]}"
+ return uuid
+
+
+def get_token_claim(token, claim):
+ """
+ Decodes a JWT token and returns the value of the specified claim.
+ """
+ try:
+ payload_b64 = token.split('.')[1]
+ # URL-safe base64 decoding usually needs padding adjustment
+ missing_padding = len(payload_b64) % 4
+ if missing_padding:
+ payload_b64 += '=' * (4 - missing_padding)
+ # Use urlsafe_b64decode just in case, though standard b64decode often works with padding
+ payload_json = base64.urlsafe_b64decode(payload_b64).decode('utf-8')
+ payload = json.loads(payload_json)
+ return payload.get(claim)
+ except (ValueError, IndexError, json.JSONDecodeError) as e:
+ print(f"Failed to decode token for claim '{claim}': {e}")
+ return None
diff --git a/.github/workflows/tests/test_knoxidf.py b/.github/workflows/tests/test_knoxidf.py
new file mode 100644
index 0000000000..cbbbacaf2d
--- /dev/null
+++ b/.github/workflows/tests/test_knoxidf.py
@@ -0,0 +1,356 @@
+# 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
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Integration tests for Knox as an OIDC Identity Federation (IDF) provider."""
+
+import unittest
+import hashlib
+import base64
+from urllib.parse import urlparse, parse_qs
+from requests.auth import HTTPBasicAuth
+
+from common_utils import (
+ gateway_base_url,
+ knox_get,
+ knox_post,
+ get_token_claim,
+ get_token_id_display_text,
+)
+
+
+class TestKnoxIDF(unittest.TestCase):
+ """OIDC provider tests covering discovery, client credentials, and auth code flows."""
+
+ def setUp(self):
+ # Get the Knox Gateway URL from environment variables
+ self.base_url = gateway_base_url()
+ self.knoxidf_ldap_url = f"{self.base_url}gateway/knoxidf-ldap/"
+ self.knoxidf_token_url = f"{self.base_url}gateway/knoxidf-token/"
+ self.username = "guest"
+ self.password = "guest-password"
+
+ def test_discovery(self):
+ """
+ Test OIDC Discovery endpoint.
+ """
+ url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/.well-known/openid-configuration"
+ print(f"Testing Discovery URL: {url}")
+ response = knox_get(url)
+ self.assertEqual(response.status_code, 200)
+ config = response.json()
+
+ # Construct expected values based on dynamic base_url
+ expected_issuer = f"{self.knoxidf_ldap_url}knoxidf"
+ expected_auth_endpoint = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize"
+ expected_token_endpoint = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
+ expected_userinfo_endpoint = f"{self.knoxidf_token_url}knoxidf/api/v1/userinfo"
+ expected_jwks_uri = f"{self.knoxidf_ldap_url}knoxidf/api/v1/jwks"
+
+ self.assertEqual(config.get("issuer"), expected_issuer)
+ self.assertEqual(config.get("authorization_endpoint"), expected_auth_endpoint)
+ self.assertEqual(config.get("token_endpoint"), expected_token_endpoint)
+ self.assertEqual(config.get("userinfo_endpoint"), expected_userinfo_endpoint)
+ self.assertEqual(config.get("jwks_uri"), expected_jwks_uri)
+
+ self.assertEqual(config.get("response_types_supported"), ["code"])
+ self.assertEqual(
+ config.get("grant_types_supported"),
+ ["authorization_code", "refresh_token"],
+ )
+ self.assertEqual(config.get("id_token_signing_alg_values_supported"), ["RS256"])
+ self.assertEqual(
+ config.get("scopes_supported"),
+ ["openid", "email", "profile", "offline_access"],
+ )
+
+ def test_client_credentials_flow(self):
+ """
+ Test OIDC Client Credentials Flow.
+ """
+ # 1. Register client
+ client_id, client_secret = self._register_test_client()
+
+ # 2. Get token via client_credentials
+ token_url = f"{self.knoxidf_token_url}knoxtoken/api/v1/token"
+ print(f"Getting token at: {token_url}")
+ data = {
+ "grant_type": "client_credentials",
+ "scope": "openid",
+ "client_id": client_id,
+ "client_secret": client_secret
+ }
+ # ClientCredentialsResource uses Basic Auth for client authentication
+ response = knox_post(token_url, data=data, verify=False)
+ if response.status_code != 200:
+ print(f"Token error response: {response.text}")
+ self.assertEqual(response.status_code, 200)
+ tokens = response.json()
+ self.assertIn("access_token", tokens)
+ self.assertEqual(tokens["token_type"], "Bearer")
+
+ def test_authorization_code_flow(self):
+ """
+ Test OIDC Authorization Code Flow with Refresh Token.
+ """
+ # 1. Register client
+ client_id, client_secret = self._register_test_client()
+
+ # 2. Authorize (with Basic Auth for the user 'guest')
+ params = {
+ "response_type": "code",
+ "client_id": client_id,
+ "redirect_uri": "http://localhost/callback",
+ "scope": "openid offline_access",
+ "state": "test_state",
+ "auto_consent": "true"
+ }
+ code = self._authorize_get_code(params, expect_state="test_state")
+
+ # 3. Exchange code for tokens
+ token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
+ print(f"Exchanging code for tokens at: {token_url}")
+ data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": "http://localhost/callback",
+ "client_id": client_id,
+ "client_secret": client_secret
+ }
+ response = knox_post(token_url, data=data, verify=False)
+ if response.status_code != 200:
+ print(f"Code exchange error: {response.text}")
+ self.assertEqual(response.status_code, 200)
+ tokens = response.json()
+ self.assertIn("access_token", tokens)
+ self.assertIn("id_token", tokens)
+ self.assertIn("refresh_token", tokens)
+
+ refresh_token = tokens["refresh_token"]
+ print(f"Refresh token: {refresh_token}")
+ refresh_token_id = get_token_claim(refresh_token, 'knox.id')
+ print(f"Refresh token knox.id: {refresh_token_id}")
+
+ # 4. Refresh the token (rotation)
+ print("Refreshing token...")
+ data = {
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": client_id,
+ "client_secret": client_secret
+ }
+ response = knox_post(token_url, data=data, verify=False)
+ self.assertEqual(response.status_code, 200)
+ new_tokens = response.json()
+ self.assertIn("access_token", new_tokens)
+ self.assertIn("refresh_token", new_tokens)
+
+ # Verify rotation: new refresh token should be different
+ self.assertNotEqual(refresh_token, new_tokens["refresh_token"])
+
+ # 5. Verify old refresh token is invalidated
+ print("Verifying old refresh token is invalidated...")
+ # Use same data (with old refresh_token)
+ data_old = {
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": client_id,
+ "client_secret": client_secret
+ }
+ response = knox_post(
+ token_url,
+ data=data_old,
+ verify=False,
+ headers={"Accept": "application/json"},
+ )
+ self.assertEqual(response.status_code, 401)
+ error_info = response.json()
+ display_id = get_token_id_display_text(refresh_token_id)
+ self.assertEqual(error_info["status"], "401")
+ self.assertIn(f"Unknown token: {display_id}", error_info["message"])
+
+ def test_authorization_code_flow_pkce_s256(self):
+ """
+ Test OIDC Authorization Code Flow with PKCE (S256).
+ """
+ # 1. Register client
+ client_id, client_secret = self._register_test_client()
+
+ # 2. PKCE Setup
+ code_verifier = "thisshouldbealongandrandomstringthatissecure"
+ code_challenge = self._s256_challenge(code_verifier)
+
+ # 3. Authorize
+ params = {
+ "response_type": "code",
+ "client_id": client_id,
+ "redirect_uri": "http://localhost/callback",
+ "scope": "openid",
+ "state": "pkce_state",
+ "auto_consent": "true",
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256"
+ }
+ code = self._authorize_get_code(params)
+
+ # 4. Token Exchange
+ token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
+ data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": "http://localhost/callback",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "code_verifier": code_verifier
+ }
+ response = knox_post(token_url, data=data)
+ self.assertEqual(response.status_code, 200)
+ tokens = response.json()
+ self.assertIn("access_token", tokens)
+
+ def test_authorization_code_flow_pkce_plain(self):
+ """
+ Test OIDC Authorization Code Flow with PKCE (plain).
+ """
+ # 1. Register client
+ client_id, client_secret = self._register_test_client()
+
+ # 2. PKCE Setup
+ code_verifier = "some-plain-verifier"
+ code_challenge = code_verifier
+
+ # 3. Authorize
+ params = {
+ "response_type": "code",
+ "client_id": client_id,
+ "redirect_uri": "http://localhost/callback",
+ "scope": "openid",
+ "state": "pkce_plain_state",
+ "auto_consent": "true",
+ "code_challenge": code_challenge,
+ "code_challenge_method": "plain"
+ }
+ code = self._authorize_get_code(params)
+
+ # 4. Token Exchange
+ token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
+ data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": "http://localhost/callback",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "code_verifier": code_verifier
+ }
+ response = knox_post(token_url, data=data)
+ self.assertEqual(response.status_code, 200)
+ tokens = response.json()
+ self.assertIn("access_token", tokens)
+
+ def test_authorization_code_flow_pkce_failure(self):
+ """
+ Test PKCE Failure scenarios.
+ """
+ client_id, client_secret = self._register_test_client()
+ code_verifier = "correct-verifier"
+ code_challenge = self._s256_challenge(code_verifier)
+
+ # Authorize
+ params = {
+ "response_type": "code",
+ "client_id": client_id,
+ "redirect_uri": "http://localhost/callback",
+ "scope": "openid",
+ "state": "pkce_fail",
+ "auto_consent": "true",
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256"
+ }
+ code = self._authorize_get_code(params)
+
+ token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
+
+ # 1. Invalid verifier
+ data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": "http://localhost/callback",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "code_verifier": "wrong-verifier"
+ }
+ response = knox_post(token_url, data=data)
+ self.assertEqual(response.status_code, 401)
+ self.assertIn("Invalid code_verifier", response.json()["error_description"])
+
+ # Note: the code is revoked after first use, so we need a new one for the next test
+ code = self._authorize_get_code(params)
+
+ # 2. Missing verifier
+ data = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": "http://localhost/callback",
+ "client_id": client_id,
+ "client_secret": client_secret
+ }
+ response = knox_post(token_url, data=data)
+ self.assertEqual(response.status_code, 401)
+ self.assertIn("Missing code_verifier", response.json()["error_description"])
+
+ def _register_test_client(self):
+ reg_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/client/register"
+ print(f"Registering client at: {reg_url}")
+ data = {
+ "redirect_uris": "http://localhost/callback",
+ "allowed_scopes": "openid,profile,email,offline_access"
+ }
+ response = knox_post(reg_url, data=data, auth=HTTPBasicAuth(self.username, self.password))
+ self.assertEqual(response.status_code, 200)
+ reg_info = response.json()
+ print(f"Registration response: {reg_info}")
+ return reg_info["client_id"], reg_info["client_secret"]
+
+ def _authorize_get_code(self, params, expect_state=None):
+ """Hit the authorize endpoint and return the code from the redirect Location."""
+ auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize"
+ print(f"Authorizing at: {auth_url}")
+ # allow_redirects=False to catch the redirect to redirect_uri
+ response = knox_get(
+ auth_url,
+ params=params,
+ auth=(self.username, self.password),
+ verify=False,
+ allow_redirects=False,
+ )
+ self.assertEqual(response.status_code, 303)
+ location = response.headers.get("Location")
+ self.assertIsNotNone(location)
+ self.assertTrue(location.startswith("http://localhost/callback"))
+
+ query_params = parse_qs(urlparse(location).query)
+ self.assertIn("code", query_params)
+ if expect_state is not None:
+ self.assertIn("state", query_params)
+ self.assertEqual(query_params["state"][0], expect_state)
+ return query_params["code"][0]
+
+ @staticmethod
+ def _s256_challenge(code_verifier):
+ digest = hashlib.sha256(code_verifier.encode()).digest()
+ return base64.urlsafe_b64encode(digest).decode().replace('=', '')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/build-tools/src/main/resources/build-tools/spotbugs-filter.xml b/build-tools/src/main/resources/build-tools/spotbugs-filter.xml
index fb4d7857d4..d27630435e 100644
--- a/build-tools/src/main/resources/build-tools/spotbugs-filter.xml
+++ b/build-tools/src/main/resources/build-tools/spotbugs-filter.xml
@@ -85,4 +85,9 @@ limitations under the License.
+
+
+
+
+
diff --git a/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js b/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js
index 4f91c2fca2..5a304a5565 100644
--- a/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js
+++ b/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js
@@ -16,7 +16,8 @@
*/
var loginPageSuffix = "/knoxauth/login.html";
-var webssoURL = "/api/v1/websso?originalUrl=";
+var webssoURLBase = "/api/v1/websso";
+var webssoURL = webssoURLBase + "?originalUrl=";
var userAgent = navigator.userAgent.toLowerCase();
function get(name) {
@@ -26,6 +27,11 @@ function get(name) {
}
}
+function getSimpleParam(name) {
+ const params = new URLSearchParams(window.location.search);
+ return params.get(name);
+}
+
function testSameOrigin(url) {
var loc = window.location,
a = document.createElement('a');
@@ -55,6 +61,35 @@ var keypressed = function(event) {
}
};
+var loadFederatedOpLinks = function() {
+ const ops = getSimpleParam("federatedOpNames")?.split(",") ?? [];
+ const container = $("#federated-op-container");
+
+ if (ops.length > 0) {
+ container.before(`
+
"
diff --git a/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java b/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java
new file mode 100644
index 0000000000..f74ddd55f1
--- /dev/null
+++ b/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.identityasserter.common.filter;
+
+import org.apache.knox.gateway.audit.log4j.audit.Log4jAuditService;
+import org.apache.knox.gateway.context.ContextAttributes;
+import org.apache.knox.gateway.security.ActorChainPrincipal;
+import org.apache.knox.gateway.security.ActorChainPrincipalImpl;
+import org.apache.knox.gateway.security.ImpersonatedPrincipal;
+import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.logging.log4j.ThreadContext;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.security.auth.Subject;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.security.PrivilegedExceptionAction;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Regression tests for the RFC 8693 token-exchange processing pipeline:
+ * {@link AbstractIdentityAssertionFilter#continueChainAsPrincipal} handling of
+ * {@code TokenExchangePrincipal} (TEP) and {@code ActorChainPrincipal}.
+ *
+ *
Each test constructs a Subject directly (bypassing the JWT filter) and runs it through
+ * a minimal anonymous subclass of {@link CommonIdentityAssertionFilter} with identity
+ * {@code mapUserPrincipal} (returns input unchanged) and null {@code mapGroupPrincipals}
+ * (no group mapping). A {@link SubjectCapturingChain} captures the Subject visible to
+ * downstream filters inside whatever doAs context is active at chain invocation time.
+ *
+ *
Abbreviations used: AIAF for AbstractIdentityAssertionFilter and
+ * TEP for TokenExchangePrincipal.
+ *
+ */
+public class AbstractIdentityAssertionFilterTokenExchangeTest {
+
+ private CommonIdentityAssertionFilter filter;
+ private FilterConfig filterConfig;
+
+ @Before
+ public void setUp() throws Exception {
+ filter = new CommonIdentityAssertionFilter() {
+ @Override
+ public String mapUserPrincipal(String principalName) {
+ return principalName;
+ }
+
+ @Override
+ public String[] mapGroupPrincipals(String name, Subject subject,
+ ServletRequest request) {
+ return null;
+ }
+ };
+
+ ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+ EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE))
+ .andReturn("test-topology").anyTimes();
+ ctx.setAttribute(
+ EasyMock.eq(ContextAttributes.IMPERSONATION_ENABLED_ATTRIBUTE),
+ EasyMock.anyObject());
+ EasyMock.expectLastCall().anyTimes();
+ EasyMock.replay(ctx);
+
+ filterConfig = EasyMock.createNiceMock(FilterConfig.class);
+ EasyMock.expect(filterConfig.getServletContext()).andReturn(ctx).anyTimes();
+ EasyMock.expect(filterConfig.getInitParameter(
+ CommonIdentityAssertionFilter.PRINCIPAL_MAPPING)).andReturn(null).anyTimes();
+ EasyMock.expect(filterConfig.getInitParameter(
+ CommonIdentityAssertionFilter.GROUP_PRINCIPAL_MAPPING)).andReturn(null).anyTimes();
+ EasyMock.expect(filterConfig.getInitParameter(
+ CommonIdentityAssertionFilter.ADVANCED_PRINCIPAL_MAPPING))
+ .andReturn("username").anyTimes();
+ EasyMock.expect(filterConfig.getInitParameterNames())
+ .andReturn(Collections.emptyEnumeration()).anyTimes();
+ EasyMock.replay(filterConfig);
+
+ filter.init(filterConfig);
+ ThreadContext.put(Log4jAuditService.MDC_AUDIT_CONTEXT_KEY, "dummy");
+ }
+
+ /**
+ * When TEP identifies different actor and subject, AIAF creates a new doAs Subject with an
+ * ImpersonatedPrincipal set to the subject identity and PrimaryPrincipal preserved as the actor.
+ */
+ @Test
+ public void testTEPWithDifferentActorAndSubjectSetsUpImpersonation() throws Exception {
+ Subject subject = buildSubject(
+ new PrimaryPrincipal("sa-actor"),
+ new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null));
+
+ SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+ Assert.assertTrue("chain should have been called", chain.called);
+ Set impersonated = chain.subject.getPrincipals(ImpersonatedPrincipal.class);
+ Assert.assertEquals("Expected exactly one ImpersonatedPrincipal", 1, impersonated.size());
+ Assert.assertEquals("ImpersonatedPrincipal should be end-user", "end-user",
+ impersonated.iterator().next().getName());
+ Set primary = chain.subject.getPrincipals(PrimaryPrincipal.class);
+ Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, primary.size());
+ Assert.assertEquals("PrimaryPrincipal should be sa-actor", "sa-actor",
+ primary.iterator().next().getName());
+ }
+
+ /**
+ * When TEP actor and subject are the same identity, no impersonation is needed and AIAF
+ * proceeds without adding an ImpersonatedPrincipal to the downstream Subject.
+ */
+ @Test
+ public void testTEPWithSameActorAndSubjectSkipsImpersonation() throws Exception {
+ Subject subject = buildSubject(
+ new PrimaryPrincipal("alice"),
+ new TokenExchangePrincipalImpl("alice", null, "alice", null));
+
+ SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+ Assert.assertTrue("chain should have been called", chain.called);
+ Assert.assertTrue("ImpersonatedPrincipal set should be empty",
+ chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
+ }
+
+ /**
+ * When no TEP is present, AIAF proceeds normally without creating an ImpersonatedPrincipal
+ * and the downstream Subject contains no TokenExchangePrincipal.
+ */
+ @Test
+ public void testNoTEPProceedsNormally() throws Exception {
+ Subject subject = buildSubject(new PrimaryPrincipal("alice"));
+
+ SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+ Assert.assertTrue("chain should have been called", chain.called);
+ Assert.assertTrue("ImpersonatedPrincipal set should be empty",
+ chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
+ Assert.assertNull("No TokenExchangePrincipal expected",
+ SubjectUtils.getTokenExchangePrincipal(chain.subject));
+ }
+
+ /**
+ * Principal mapping is applied to the subject identity from TEP (not to the actor identity).
+ * AIAF calls {@code mapUserPrincipal} on {@code tep.getSubjectPrincipalName()} and uses the
+ * mapped result as the ImpersonatedPrincipal; the actor (PrimaryPrincipal) is unchanged.
+ */
+ @Test
+ public void testTEPAppliesPrincipalMappingToSubjectNotActor() throws Exception {
+ CommonIdentityAssertionFilter mappingFilter = new CommonIdentityAssertionFilter() {
+ @Override
+ public String mapUserPrincipal(String principalName) {
+ return "user@external".equals(principalName) ? "localuser" : principalName;
+ }
+
+ @Override
+ public String[] mapGroupPrincipals(String name, Subject subject,
+ ServletRequest request) {
+ return null;
+ }
+ };
+ mappingFilter.init(filterConfig);
+
+ Subject subject = buildSubject(
+ new PrimaryPrincipal("sa-actor"),
+ new TokenExchangePrincipalImpl("user@external", null, "sa-actor", null));
+
+ SubjectCapturingChain chain = runFilterWithSubject(subject, mappingFilter);
+
+ Set impersonated = chain.subject.getPrincipals(ImpersonatedPrincipal.class);
+ Assert.assertEquals("Expected exactly one ImpersonatedPrincipal", 1, impersonated.size());
+ Assert.assertEquals("ImpersonatedPrincipal should be mapped value", "localuser",
+ impersonated.iterator().next().getName());
+ Set primary = chain.subject.getPrincipals(PrimaryPrincipal.class);
+ Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, primary.size());
+ Assert.assertEquals("PrimaryPrincipal should be actor (unmapped)", "sa-actor",
+ primary.iterator().next().getName());
+ }
+
+ /**
+ * The TokenExchangePrincipal is preserved in the new doAs Subject built by AIAF when
+ * impersonation is needed, so downstream filters can still read the delegation metadata.
+ */
+ @Test
+ public void testTEPPreservedInDoAsSubject() throws Exception {
+ Subject subject = buildSubject(
+ new PrimaryPrincipal("sa-actor"),
+ new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null));
+
+ SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+ Assert.assertNotNull("TokenExchangePrincipal should be preserved in downstream Subject",
+ SubjectUtils.getTokenExchangePrincipal(chain.subject));
+ }
+
+ /**
+ * The ActorChainPrincipal is preserved in the new doAs Subject built by AIAF when
+ * impersonation is needed, so the full delegation chain history is available downstream.
+ */
+ @Test
+ public void testActorChainPrincipalPreservedInDoAsSubject() throws Exception {
+ List