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:03:29 UTC

(knox) branch knox_idf updated (705d27e9f -> e43cd2e52)

This is an automated email from the ASF dual-hosted git repository.

smolnar82 pushed a change to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git


 discard 705d27e9f KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute on token-exchange (#1342)
    omit 2ff4e39e2 KNOX-3408 - Move act-claim/ActorChainPrincipal coverage into TokenExchangeHandlerTest; drop redundant JWTFederationFilterHandleTokenExchangeTest
    omit 58865312a KnoxIDF: remove unused import (caused by the previous master rebase)
    omit 5066017a8 KNOX-3408 - Allow no actor token in JWTFederationFilter.handleTokenExchange (#1339)
    omit 2d3e5acef KNOX-3368 - Switch KNOXIDF_ADMIN to single-role pattern with PathAclsAuthz (#1337)
    omit 2e5a04dec KNOX-3396: Return 4xx for type-mismatched register bodies and issuer-limit-reached instead of 500 (#1328)
    omit a425278dd KNOX-3390: Corrected JUnit test coverage for max issuers (#1326)
    omit 612810fe1 KNOX-3368 - Trusted OIDC Issuer admin API for Knox IDF (#1327)
    omit 232e9b1a7 KNOX-3390: Moved gateway-level config to GatewayConfig and implemented the missing methods (#1322)
    omit 3bc9e46c2 KNOX-3390 - Address comments in PR 1315 (#1320)
    omit b697d7afc KNOX-3355 - Add OIDCDiscoveryHelper, JdbcTrustedOidcIssuerService, and TrustedOidcIssuerServiceFactory (#1315)
    omit 084f7287e KNOX-3355 - Add TrustedOidcIssuerService schema and interface (#1311)
    omit b8d9ceaea KnoxIDF - Fixed pylint and test issues in Docker-based tests
    omit 90e1e7e8a Knox as OIDC Provider (#1215)
     add e51163532 KNOX-3409: Validate knoxauth theme name to Harden Branding Support (Reported by Quinn Nguyen) (#1341)
     add f2126008a KNOX-3410: KnoxToken enable/disable endpoints perform no caller autho… (#1343)
     add 049dc0cb8 KNOX-3411: KnoxToken getUserTokens returns every user's token metadat… (#1344)
     add 5342483a2 KNOX-3413: KnoxToken passcode verification accepts a valid passcode for a different token (#1345)
     add c3dee5634 KNOX-3416: KnoxSSO redirects to untrusted site (#1346)
     new 5da3d9be6 Knox as OIDC Provider (#1215)
     new f980f6ac0 KnoxIDF - Fixed pylint and test issues in Docker-based tests
     new e0aab56b5 KNOX-3355 - Add TrustedOidcIssuerService schema and interface (#1311)
     new b0f6e27b3 KNOX-3355 - Add OIDCDiscoveryHelper, JdbcTrustedOidcIssuerService, and TrustedOidcIssuerServiceFactory (#1315)
     new f7175b88d KNOX-3390 - Address comments in PR 1315 (#1320)
     new 7afb4b483 KNOX-3390: Moved gateway-level config to GatewayConfig and implemented the missing methods (#1322)
     new 740c6960c KNOX-3368 - Trusted OIDC Issuer admin API for Knox IDF (#1327)
     new f920ec986 KNOX-3390: Corrected JUnit test coverage for max issuers (#1326)
     new de7c31270 KNOX-3396: Return 4xx for type-mismatched register bodies and issuer-limit-reached instead of 500 (#1328)
     new 85117644d KNOX-3368 - Switch KNOXIDF_ADMIN to single-role pattern with PathAclsAuthz (#1337)
     new b6f674d42 KNOX-3408 - Allow no actor token in JWTFederationFilter.handleTokenExchange (#1339)
     new c8ab014f0 KnoxIDF: remove unused import (caused by the previous master rebase)
     new baefcd58a KNOX-3408 - Move act-claim/ActorChainPrincipal coverage into TokenExchangeHandlerTest; drop redundant JWTFederationFilterHandleTokenExchangeTest
     new cd163171a KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute on token-exchange (#1342)
     new d9b1453d2 KNOX-3414: security hardening for the OIDC provider (Tier 1 + Tier 2)
     new dcd1e0177 KNOX-3414: harden secrets-at-rest, auth-code single-use, and persistence (1.5, 2.4, 2.11)
     new 7802c3c65 KNOX-3414: Tier 3 hardening + Tier 4 polish for the OIDC provider
     new c8af244e4 KNOX-3414: E2E tests now adjusted to cover the previous Tier 1/2/3 bug fixes and changes
     new 9a1d04a2b KNOX-3414: add opt-in Keycloak federation E2E test and activate federated-identity persistence
     new 6c048894e KNOX-3414: authenticate the client on the refresh_token grant (review finding H1)
     new 571a9d97f KNOX-3414: bind federated id_token to the request via OIDC nonce (review finding H2)
     new 5926b85af KNOX-3414: atomically consume the refresh token before rotation (review finding M1)
     new 8f6860b03 KNOX-3414: reject federated id_token missing sub with 4xx, not 500 (review finding M2)
     new ce091a2fb KNOX-3414: normalize redirect_uri path before wildcard match (review finding M3)
     new 147310292 KNOX-3414: fail closed when a federated-OP client-secret alias is unresolvable (review finding M4)
     new 2abbf2a83 KNOX-3414: fix broken double-checked locking in JdbcTrustedOidcIssuerService.init (review finding M5)
     new 478c56181 KNOX-3414: /userinfo returns 401 invalid_token, not 500, for a bad bearer token (review finding M6)
     new 5827bb196 KNOX-3414: build a well-formed success redirect when redirect_uri has a query (review finding M7)
     new c74d837e0 KNOX-3414: advertise only S256 in discovery code_challenge_methods_supported (review finding M8)
     new 5e7d83f88 KNOX-3414: emit structured audit records across OAuth2/OIDC endpoints (J2)
     new a2c1470c8 KNOX-3414: publish multiple JWKs and select verification key by kid
     new 7341b1b89 KNOX-3414: complete OIDC discovery metadata for OAuth/MCP client integration
     new 34d403040 KNOX-3414: document the UUIDv5 namespace used for federated subject derivation
     new 0bcb52a38 KNOX-3414: add Identity Federation (OIDC Provider) documentation book
     new 8d6840b29 KNOX-3414: run KnoxIDF federation E2E tests optionally in PRs
     new 331f3bbd2 KNOX-3414: fix embedded-Derby table-exists check and add DerbyDB TrustedOidcIssuerService default
     new e43cd2e52 KNOX-3414: support Authorization Code + PKCE for public clients

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (705d27e9f)
            \
             N -- N -- N   refs/heads/knox_idf (e43cd2e52)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 37 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .github/PULL_REQUEST_TEMPLATE.md                   |  10 +
 .github/workflows/build/Dockerfile                 |   2 +
 .../build/conf/topologies/knoxidf-ldap.xml         |  15 +
 .../build/conf/topologies/knoxidf-sso.xml          | 120 +++++
 .../build/conf/topologies/knoxidf-token.xml        |   7 +
 .../workflows/build}/conf/topologies/knoxsso.xml   |  61 +--
 .../compose/docker-compose.knoxidf-federation.yml  |  73 +++
 .github/workflows/compose/docker-compose.yml       |   2 +-
 .github/workflows/compose/keycloak/realm.json      |  49 ++
 .github/workflows/tests.yml                        |  56 +++
 .github/workflows/tests/test_knoxidf.py            |  75 ++-
 .github/workflows/tests/test_knoxidf_federation.py | 234 +++++++++
 .gitignore                                         |   4 +
 .../applications/knoxauth/app/js/knoxauth.js       |  14 +-
 .../resources/applications/knoxauth/app/login.html |  72 ++-
 .../knoxauth/app/styles/themes/DEPLOYMENT.md       |  18 +-
 .../knoxauth/app/styles/themes/README.md           |  15 +-
 .../federation/jwt/filter/AbstractJWTFilter.java   |   9 +-
 .../federation/jwt/filter/JWTFederationFilter.java |  37 +-
 .../provider/federation/AbstractJWTFilterTest.java |   3 +
 .../federation/JWTFederationFilterTest.java        |   2 +-
 .../federation/OAuthFlowsFederationFilterTest.java |  83 +++-
 ...okenIDAsHTTPBasicCredsFederationFilterTest.java |  50 ++
 .../knox/gateway/filter/RedirectToUrlFilter.java   |   5 +-
 .../org/apache/knox/gateway/GatewayMessages.java   |   6 +
 .../gateway/config/impl/GatewayConfigImpl.java     |  19 +
 .../apache/knox/gateway/database/JDBCUtils.java    |  20 +-
 .../apache/knox/gateway/database/KnoxDatabase.java |   7 +-
 .../services/factory/AbstractServiceFactory.java   |  79 +++
 .../factory/FederatedIdentityServiceFactory.java   |  91 ++--
 .../factory/TrustedOidcIssuerServiceFactory.java   |  86 ++--
 .../DerbyDBFederatedIdentityService.java}          |  40 +-
 ...ice.java => EmptyFederatedIdentityService.java} |   2 +-
 .../federation/FederatedIdentityDatabase.java      |  56 ++-
 .../FederatedIdentityServiceMessages.java          |   3 +
 .../federation/JdbcFederatedIdentityService.java   |  54 ++-
 .../DerbyDBTrustedOidcIssuerService.java}          |  41 +-
 .../JdbcTrustedOidcIssuerService.java              |  36 +-
 .../token/impl/DefaultTokenAuthorityService.java   |  54 ++-
 .../token/impl/DefaultTokenStateService.java       |  16 +
 .../services/token/impl/JDBCTokenStateService.java |  19 +
 ...eateKnoxIDFFederatedIdentityAttributesTable.sql |   2 +-
 ...noxIDFFederatedIdentityAttributesTableDerby.sql |   6 +-
 ...oxIDFFederatedIdentityAttributesTableOracle.sql |   2 +-
 .../createKnoxIDFFederatedIdentityTable.sql        |   7 +-
 .../createKnoxIDFFederatedIdentityTableDerby.sql   |  15 +-
 .../createKnoxIDFFederatedIdentityTableOracle.sql  |   7 +-
 .../gateway/config/impl/GatewayConfigImplTest.java |  28 ++
 .../FederatedIdentityServiceFactoryTest.java       | 211 ++++++++
 .../TrustedOidcIssuerServiceFactoryTest.java       | 188 ++++++++
 .../DerbyDBFederatedIdentityServiceTest.java       | 150 ++++++
 .../DerbyDBTrustedOidcIssuerServiceTest.java       | 131 +++++
 .../JdbcTrustedOidcIssuerServiceTest.java          |  56 +++
 .../TrustedOidcIssuerServiceFactoryTest.java       | 265 ----------
 .../impl/DefaultTokenAuthorityServiceTest.java     |  64 +++
 .../token/impl/DefaultTokenStateServiceTest.java   |  36 ++
 .../service/knoxidf/AuthConsentServlet.java        |   6 +-
 .../gateway/service/knoxidf/AuthorizeResource.java | 428 +++++++++++++++--
 .../gateway/service/knoxidf/DiscoveryResource.java |  31 +-
 .../knox/gateway/service/knoxidf/JwksResource.java |   4 +-
 .../knox/gateway/service/knoxidf/KnoxIDFAudit.java |  91 ++++
 .../service/knoxidf/RegistrationResource.java      | 140 +++++-
 .../gateway/service/knoxidf/TokenResource.java     | 322 ++++++++++---
 .../gateway/service/knoxidf/UserInfoResource.java  | 132 +++--
 .../knoxidf/userparams/LdapUserParamsProvider.java |  17 +-
 ...uthorizeResourceClientSecretResolutionTest.java |  59 +++
 .../AuthorizeResourceFederatedNonceTest.java       |  75 +++
 .../AuthorizeResourceFederatedSubjectTest.java     |  68 +++
 .../AuthorizeResourceRedirectUriMatchTest.java     |  74 +++
 .../AuthorizeResourceSuccessRedirectTest.java      |  67 +++
 .../service/knoxidf/ConsentMetadataKeyTest.java    |  65 +++
 .../knoxidf/DiscoveryResourceMetadataTest.java     |  69 +++
 .../knoxidf/DiscoveryResourcePkceMethodsTest.java  |  52 ++
 .../gateway/service/knoxidf/KnoxIDFAuditTest.java  | 274 +++++++++++
 .../knoxidf/KnoxIDFUtilsErrorStatusTest.java       |  83 ++++
 .../knoxidf/RegistrationRedirectUriPolicyTest.java | 119 +++++
 .../RegistrationResourceAnonymousGuardTest.java    |  78 +++
 .../knoxidf/TokenResourceAuthCodeReplayTest.java   | 153 ++++++
 .../knoxidf/TokenResourceClientAuthTest.java       | 116 +++++
 .../TokenResourceRefreshTokenClientAuthTest.java   | 146 ++++++
 .../TokenResourceRefreshTokenRotationTest.java     | 134 ++++++
 .../knoxidf/UserInfoResourceInvalidTokenTest.java  |  94 ++++
 .../gateway/service/knoxsso/KnoxSSOMessages.java   |   4 +
 .../gateway/service/knoxsso/WebSSOResource.java    |  35 +-
 .../service/knoxsso/WebSSOResourceTest.java        |  43 ++
 .../gateway/service/knoxtoken/JWKSResource.java    |  59 ++-
 .../gateway/service/knoxtoken/TokenResource.java   |  62 ++-
 .../gateway/service/knoxtoken/TokenResourceV2.java |   2 +-
 .../service/knoxtoken/TokenServiceMessages.java    |   3 +
 .../service/knoxtoken/JWKSResourceTest.java        |  57 +++
 .../knoxtoken/TokenServiceResourceTest.java        | 270 ++++++++++-
 .../apache/knox/gateway/config/GatewayConfig.java  |  20 +
 .../security/token/JWTokenAttributesBuilder.java   |   5 +-
 .../services/security/token/TokenStateService.java |  26 +
 .../services/security/token/impl/JWTTokenTest.java |  28 ++
 .../util/knoxidf/AuthorizeRequestMetadata.java     |   7 +
 .../gateway/util/knoxidf/FederatedNonceStore.java  |  42 ++
 .../util/knoxidf/FederatedOpConfiguration.java     |  38 ++
 .../gateway/util/knoxidf/KnoxIDFArtifactStore.java |   9 +
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |  25 +-
 .../knox/gateway/util/knoxidf/KnoxIDFUtils.java    |  84 ++--
 .../util/knoxidf/FederatedOpConfigurationTest.java |  99 ++++
 .../util/knoxidf/KnoxIDFArtifactStoreTest.java     |  63 +++
 .../docs/assets/images/knoxidf/architecture.png    | Bin 0 -> 319038 bytes
 .../docs/assets/images/knoxidf/consent_page.png    | Bin 0 -> 87171 bytes
 .../assets/images/knoxidf/login_page_federated.png | Bin 0 -> 7329 bytes
 .../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/configuration.md            | 174 +++++++
 knox-site/docs/knoxidf/endpoints.md                | 299 ++++++++++++
 knox-site/docs/knoxidf/federation.md               | 221 +++++++++
 knox-site/docs/knoxidf/getting_started.md          | 284 +++++++++++
 knox-site/docs/knoxidf/index.md                    |  93 ++++
 knox-site/docs/knoxidf/integrations/polaris.md     | 533 +++++++++++++++++++++
 .../docs/knoxidf/integrations/polaris_console.md   | 471 ++++++++++++++++++
 knox-site/docs/knoxidf/operations.md               | 182 +++++++
 knox-site/docs/knoxidf/security.md                 | 183 +++++++
 knox-site/mkdocs.yml                               |  17 +-
 118 files changed, 8306 insertions(+), 842 deletions(-)
 create mode 100644 .github/workflows/build/conf/topologies/knoxidf-sso.xml
 copy {gateway-release/home => .github/workflows/build}/conf/topologies/knoxsso.xml (67%)
 create mode 100644 .github/workflows/compose/docker-compose.knoxidf-federation.yml
 create mode 100644 .github/workflows/compose/keycloak/realm.json
 create mode 100644 .github/workflows/tests/test_knoxidf_federation.py
 copy gateway-server/src/main/java/org/apache/knox/gateway/services/{token/impl/DerbyDBTokenStateService.java => knoxidf/federation/DerbyDBFederatedIdentityService.java} (75%)
 rename gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/{EmptyFederatedIdentitityService.java => EmptyFederatedIdentityService.java} (95%)
 copy gateway-server/src/main/java/org/apache/knox/gateway/services/{token/impl/DerbyDBTokenStateService.java => knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java} (74%)
 create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java
 create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java
 create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java
 create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java
 delete mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
 create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java
 create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/UserInfoResourceInvalidTokenTest.java
 create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedNonceStore.java
 create mode 100644 gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java
 create mode 100644 gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java
 create mode 100644 knox-site/docs/assets/images/knoxidf/architecture.png
 create mode 100644 knox-site/docs/assets/images/knoxidf/consent_page.png
 create mode 100644 knox-site/docs/assets/images/knoxidf/login_page_federated.png
 create mode 100644 knox-site/docs/assets/images/knoxidf/polaris_console_home.png
 create mode 100644 knox-site/docs/assets/images/knoxidf/polaris_console_login.png
 create mode 100644 knox-site/docs/knoxidf/configuration.md
 create mode 100644 knox-site/docs/knoxidf/endpoints.md
 create mode 100644 knox-site/docs/knoxidf/federation.md
 create mode 100644 knox-site/docs/knoxidf/getting_started.md
 create mode 100644 knox-site/docs/knoxidf/index.md
 create mode 100644 knox-site/docs/knoxidf/integrations/polaris.md
 create mode 100644 knox-site/docs/knoxidf/integrations/polaris_console.md
 create mode 100644 knox-site/docs/knoxidf/operations.md
 create mode 100644 knox-site/docs/knoxidf/security.md


(knox) 10/37: KNOX-3368 - Switch KNOXIDF_ADMIN to single-role pattern with PathAclsAuthz (#1337)

Posted by sm...@apache.org.
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 85117644d97922d5a7a77b5213e3668a654714ea
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Thu Aug 6 10:50:09 2026 -0700

    KNOX-3368 - Switch KNOXIDF_ADMIN to single-role pattern with PathAclsAuthz (#1337)
    
    Remove redundant admin URL paths, still allowing separate ACLs for different
    knox idf admin APIs using PathAclAuthz.
    
    Co-authored-by: Harrison <hs...@cloudera.com>
---
 .../service/knoxidf/TrustedOidcIssuersResource.java  |  2 +-
 .../KnoxIDFAdminServiceDeploymentContributor.java    | 20 ++++++++++++--------
 ...KnoxIDFAdminServiceDeploymentContributorTest.java | 13 +++++++------
 3 files changed, 20 insertions(+), 15 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
index 4bced34ff..cbfaff401 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
@@ -57,7 +57,7 @@ import java.util.stream.Collectors;
 @Produces(MediaType.APPLICATION_JSON)
 public class TrustedOidcIssuersResource {
 
-  static final String RESOURCE_PATH = "knoxidf/issuers-admin/v1/trusted-oidc-issuers";
+  static final String RESOURCE_PATH = "knoxidf/admin/v1/trusted-oidc-issuers";
 
   private static final ObjectMapper MAPPER = new ObjectMapper();
 
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java
index 060774a93..639027e8b 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java
@@ -19,13 +19,17 @@ package org.apache.knox.gateway.service.knoxidf.deploy;
 import org.apache.knox.gateway.jersey.JerseyServiceDeploymentContributorBase;
 
 /**
- * Deployment contributor for the KNOXIDF_ADMIN service role, which hosts the
- * trusted OIDC issuer admin REST API. This contributor registers
- * {@link org.apache.knox.gateway.service.knoxidf.TrustedOidcIssuersResource}
- * under the {@code knoxidf/issuers-admin/**?**} pattern, which is disjoint from
- * the KNOXIDF role's {@code knoxidf/api/**?**} pattern. This ensures the KNOXIDF
- * role cannot serve admin endpoints, and that per-role AclsAuthz authorization
- * ({@code KNOXIDF_ADMIN.acl}) applies only to trusted-issuer admin requests.
+ * Deployment contributor for the KNOXIDF_ADMIN service role, which hosts all
+ * KnoxIDF admin REST APIs under a single {@code knoxidf/admin/**?**} URL pattern.
+ * Current resources: {@link org.apache.knox.gateway.service.knoxidf.TrustedOidcIssuersResource}.
+ *
+ * <p>The {@code knoxidf/admin/**?**} pattern is disjoint from the KNOXIDF role's
+ * {@code knoxidf/api/**?**} pattern, preventing KNOXIDF from serving admin endpoints.</p>
+ *
+ * <p>Authorization: use {@code PathAclsAuthz} in the topology to assign independent
+ * ACLs to each admin endpoint (e.g., {@code KNOXIDF_ADMIN.rule_issuers.path.acl}
+ * for trusted-issuers). Alternatively, {@code AclsAuthz} with {@code KNOXIDF_ADMIN.acl}
+ * applies a single ACL to all endpoints under this role.</p>
  */
 public class KnoxIDFAdminServiceDeploymentContributor extends JerseyServiceDeploymentContributorBase {
 
@@ -46,6 +50,6 @@ public class KnoxIDFAdminServiceDeploymentContributor extends JerseyServiceDeplo
 
   @Override
   protected String[] getPatterns() {
-    return new String[] { "knoxidf/issuers-admin/**?**" };
+    return new String[] { "knoxidf/admin/**?**" };
   }
 }
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java
index cd3494b4c..578283fa4 100644
--- a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java
@@ -72,11 +72,12 @@ public class KnoxIDFAdminServiceDeploymentContributorTest {
         new KnoxIDFAdminServiceDeploymentContributor();
     final String[] patterns = c.getPatterns();
     assertNotNull(patterns);
-    // Distinct from KnoxIDFServiceDeploymentContributor's "knoxidf/api/**?**" so that the
-    // KNOXIDF role cannot accidentally serve admin endpoints, and so that per-role AclsAuthz
-    // params (KNOXIDF_ADMIN.acl) apply only to trusted-issuer admin requests.
-    assertTrue("Expected knoxidf/issuers-admin/**?** in patterns",
-        Arrays.asList(patterns).contains("knoxidf/issuers-admin/**?**"));
+    // Single broad pattern covers all KnoxIDF admin resources (trusted-issuers, delegation-policies, etc.).
+    // Disjoint from KnoxIDFServiceDeploymentContributor's "knoxidf/api/**?**" so the KNOXIDF role
+    // cannot serve admin endpoints. Per-endpoint ACLs are configured via PathAclsAuthz rules in
+    // the topology descriptor (e.g., KNOXIDF_ADMIN.rule_issuers.path.acl).
+    assertTrue("Expected knoxidf/admin/**?** in patterns",
+        Arrays.asList(patterns).contains("knoxidf/admin/**?**"));
   }
 
   @Test
@@ -140,6 +141,6 @@ public class KnoxIDFAdminServiceDeploymentContributorTest {
     contributor.contributeService(context, service);
 
     assertEquals("KNOXIDF_ADMIN", capturedRole.getValue());
-    assertEquals("knoxidf/issuers-admin/**?**", capturedPattern.getValue());
+    assertEquals("knoxidf/admin/**?**", capturedPattern.getValue());
   }
 }


(knox) 04/37: KNOX-3355 - Add OIDCDiscoveryHelper, JdbcTrustedOidcIssuerService, and TrustedOidcIssuerServiceFactory (#1315)

Posted by sm...@apache.org.
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 b0f6e27b3a13d9665d2d69788f1b08d22706f506
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Wed Jul 22 06:25:19 2026 -0700

    KNOX-3355 - Add OIDCDiscoveryHelper, JdbcTrustedOidcIssuerService, and TrustedOidcIssuerServiceFactory (#1315)
---
 gateway-server/pom.xml                             |   4 +
 .../apache/knox/gateway/database/DatabaseType.java |  29 +-
 .../gateway/services/DefaultGatewayServices.java   |   2 +
 .../factory/TrustedOidcIssuerServiceFactory.java   | 105 +++++++
 .../EmptyTrustedOidcIssuerService.java             |  81 +++++
 .../JdbcTrustedOidcIssuerService.java              | 200 +++++++++++++
 .../trustedoidcissuer/OIDCDiscoveryHelper.java     | 176 +++++++++++
 .../TrustedOidcIssuerDatabase.java                 | 101 +++++++
 .../TrustedOidcIssuerServiceMessages.java          |  51 ++++
 ...org.apache.knox.gateway.services.ServiceFactory |   1 +
 .../EmptyTrustedOidcIssuerServiceTest.java         |  64 ++++
 .../JdbcTrustedOidcIssuerServiceTest.java          | 325 +++++++++++++++++++++
 .../trustedoidcissuer/OIDCDiscoveryHelperTest.java | 303 +++++++++++++++++++
 .../TrustedOidcIssuerServiceFactoryTest.java       | 265 +++++++++++++++++
 .../TrustedOidcIssuersSchemaTest.java              |   2 +
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |  13 +
 pom.xml                                            |   6 +
 17 files changed, 1720 insertions(+), 8 deletions(-)

diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml
index 3ddfb07ab..012ffe8af 100644
--- a/gateway-server/pom.xml
+++ b/gateway-server/pom.xml
@@ -417,6 +417,10 @@
             <groupId>com.nimbusds</groupId>
             <artifactId>nimbus-jose-jwt</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.nimbusds</groupId>
+            <artifactId>oauth2-oidc-sdk</artifactId>
+        </dependency>
 
         <dependency>
             <groupId>org.apache.knox</groupId>
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
index 3f627bdac..5052d5d5a 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
@@ -24,7 +24,8 @@ public enum DatabaseType {
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
     ),
     MYSQL("mysql",
             AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -32,7 +33,8 @@ public enum DatabaseType {
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
     ),
     MARIADB("mariadb",
             AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -40,7 +42,8 @@ public enum DatabaseType {
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
     ),
     HSQL("hsql",
             AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -48,7 +51,8 @@ public enum DatabaseType {
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
     ),
     DERBY("derbydb",
             AbstractDataSourceFactory.DERBY_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -56,8 +60,8 @@ public enum DatabaseType {
             AbstractDataSourceFactory.DERBY_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.DERBY_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
-
+            AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.DERBY_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
     ),
     ORACLE("oracle",
             AbstractDataSourceFactory.ORACLE_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
@@ -65,7 +69,8 @@ public enum DatabaseType {
             AbstractDataSourceFactory.ORACLE_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.ORACLE_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.ORACLE_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL
     );
 
     private final String type;
@@ -75,8 +80,11 @@ public enum DatabaseType {
     private final String descriptorsTableSql;
     private final String federatedIdentityTableSql;
     private final String federatedIdentityAttrTableSql;
+    private final String trustedOidcIssuersTableSql;
 
-    DatabaseType(String type, String tokensTableSql, String metadataTableSql, String providersTableSql, String descriptorsTableSql, String federatedIdentityTableSql, String federatedIdentityAttrTableSql) {
+    DatabaseType(String type, String tokensTableSql, String metadataTableSql, String providersTableSql,
+        String descriptorsTableSql, String federatedIdentityTableSql, String federatedIdentityAttrTableSql,
+        String trustedOidcIssuersTableSql) {
         this.type = type;
         this.tokensTableSql = tokensTableSql;
         this.metadataTableSql = metadataTableSql;
@@ -84,6 +92,7 @@ public enum DatabaseType {
         this.descriptorsTableSql = descriptorsTableSql;
         this.federatedIdentityTableSql = federatedIdentityTableSql;
         this.federatedIdentityAttrTableSql = federatedIdentityAttrTableSql;
+        this.trustedOidcIssuersTableSql = trustedOidcIssuersTableSql;
     }
 
     public String type() {
@@ -114,6 +123,10 @@ public enum DatabaseType {
         return federatedIdentityAttrTableSql;
     }
 
+    public String trustedOidcIssuersTableSql() {
+        return trustedOidcIssuersTableSql;
+    }
+
     public static DatabaseType fromString(String dbType) {
         for (DatabaseType dt : values()) {
             if (dt.type.equalsIgnoreCase(dbType)) {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
index 2d7d12f13..39cf0aca0 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
@@ -88,6 +88,8 @@ public class DefaultGatewayServices extends AbstractGatewayServices {
     addService(ServiceType.LDAP_SERVICE, gatewayServiceFactory.create(this, ServiceType.LDAP_SERVICE, config, options));
 
     addService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, gatewayServiceFactory.create(this, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options));
+
+    addService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, gatewayServiceFactory.create(this, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options));
   }
 
   @Override
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
new file mode 100644
index 000000000..daa8d11a4
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.factory;
+
+import org.apache.knox.gateway.GatewayMessages;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.Service;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
+
+  private static final GatewayMessages LOG = MessagesFactory.get(GatewayMessages.class);
+  private static final String DEFAULT_IMPLEMENTATION = EmptyTrustedOidcIssuerService.class.getName();
+
+  @Override
+  protected Service createService(GatewayServices gatewayServices, ServiceType serviceType,
+      GatewayConfig gatewayConfig, Map<String, String> options, String implementation)
+      throws ServiceLifecycleException {
+
+    String implementationToUse = implementation;
+    if (isEmptyDefaultImplementation(implementationToUse)) {
+      if (isKnoxIdfEnabledInAnyTopology(gatewayServices)) {
+        implementationToUse = JdbcTrustedOidcIssuerService.class.getName();
+      }
+    }
+
+    TrustedOidcIssuerService service = null;
+    if (shouldCreateService(implementationToUse)) {
+      if (matchesImplementation(implementationToUse, EmptyTrustedOidcIssuerService.class, true)) {
+        service = new EmptyTrustedOidcIssuerService();
+      } else if (matchesImplementation(implementationToUse, JdbcTrustedOidcIssuerService.class)) {
+        try {
+          final JdbcTrustedOidcIssuerService jdbcService = new JdbcTrustedOidcIssuerService();
+          jdbcService.setAliasService(getAliasService(gatewayServices));
+          jdbcService.init(gatewayConfig, options);
+          service = jdbcService;
+        } catch (ServiceLifecycleException e) {
+          LOG.errorInitializingService(implementationToUse, e.getMessage(), e);
+          service = new EmptyTrustedOidcIssuerService();
+        } catch (Exception e) {
+          throw new ServiceLifecycleException(
+              "Error while creating TrustedOidcIssuerService: " + e, e);
+        }
+      }
+      if (service != null) {
+        logServiceUsage(service.getClass().getName(), serviceType);
+      }
+    }
+    return service;
+  }
+
+  /**
+   * Returns true if any deployed topology contains a service with role {@code KNOXIDF}
+   * or {@code KNOXIDF_ADMIN}. The trusted issuer registry is activated by either role
+   * because the admin API ({@code KNOXIDF_ADMIN}) also needs to persist registrations.
+   */
+  private boolean isKnoxIdfEnabledInAnyTopology(GatewayServices gatewayServices) {
+    final TopologyService topologyService = gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE);
+    if (topologyService != null) {
+      for (Topology topology : topologyService.getTopologies()) {
+        if (topology.getServices().stream().anyMatch(
+            s -> "KNOXIDF".equals(s.getRole()) || "KNOXIDF_ADMIN".equals(s.getRole()))) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  @Override
+  protected ServiceType getServiceType() {
+    return ServiceType.TRUSTED_OIDC_ISSUER_SERVICE;
+  }
+
+  @Override
+  protected Collection<String> getKnownImplementations() {
+    return List.of(DEFAULT_IMPLEMENTATION, JdbcTrustedOidcIssuerService.class.getName());
+  }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java
new file mode 100644
index 000000000..506b31d65
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * No-op stub used when the KNOXIDF or KNOXIDF_ADMIN service role is not deployed.
+ * Read methods return safe empty results; mutating methods throw
+ * {@link UnsupportedOperationException}.
+ */
+public class EmptyTrustedOidcIssuerService implements TrustedOidcIssuerService {
+
+  @Override
+  public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
+  }
+
+  @Override
+  public void start() throws ServiceLifecycleException {
+  }
+
+  @Override
+  public void stop() throws ServiceLifecycleException {
+  }
+
+  @Override
+  public boolean isTrusted(String issuerUrl) {
+    return false;
+  }
+
+  @Override
+  public boolean isDynamicJwks(String issuerUrl) {
+    return false;
+  }
+
+  @Override
+  public Optional<String> resolveJwksUri(String issuerUrl) {
+    return Optional.empty();
+  }
+
+  @Override
+  public void refreshJwksUri(String issuerUrl) {
+  }
+
+  @Override
+  public void register(TrustedOidcIssuer issuer) {
+    throw new UnsupportedOperationException("TrustedOidcIssuerService is not enabled; "
+        + "deploy the KNOXIDF or KNOXIDF_ADMIN service role to activate it.");
+  }
+
+  @Override
+  public void deregister(String issuerUrl) {
+    throw new UnsupportedOperationException("TrustedOidcIssuerService is not enabled; "
+        + "deploy the KNOXIDF or KNOXIDF_ADMIN service role to activate it.");
+  }
+
+  @Override
+  public List<TrustedOidcIssuer> list() {
+    return Collections.emptyList();
+  }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
new file mode 100644
index 000000000..0b1e0a31e
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.database.DataSourceProvider;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
+
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * JDBC-backed implementation of {@link TrustedOidcIssuerService}.
+ * <p>
+ * Maintains an in-memory registry snapshot as an {@link AtomicReference} to an immutable
+ * {@link Map}. Reads ({@link #isTrusted}, {@link #isDynamicJwks}, {@link #list}) are
+ * lock-free and always see a consistent snapshot. Writes ({@link #register},
+ * {@link #deregister}) are synchronized: the DB is committed first, then the snapshot is
+ * rebuilt from a fresh SELECT to guarantee the in-memory state cannot diverge from
+ * persistent storage.
+ * <p>
+ * HA note: each Knox node maintains its own snapshot. A registration on node A updates
+ * that node's snapshot immediately; other nodes' snapshots remain stale until restart.
+ */
+public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService {
+
+  private static final TrustedOidcIssuerServiceMessages LOG =
+      MessagesFactory.get(TrustedOidcIssuerServiceMessages.class);
+
+  static final String MAX_TRUSTED_ISSUERS_CONFIG = "gateway.trustedoidcissuer.max.issuers";
+  private static final int DEFAULT_MAX_TRUSTED_ISSUERS = 10_000;
+
+  private final AtomicBoolean initialized = new AtomicBoolean(false);
+  private final Lock initLock = new ReentrantLock(true);
+
+  private final AtomicReference<Map<String, TrustedOidcIssuer>> registrySnapshot =
+      new AtomicReference<>(Collections.emptyMap());
+
+  private AliasService aliasService;
+  private TrustedOidcIssuerDatabase database;
+  private OIDCDiscoveryHelper discoveryHelper;
+  private int maxTrustedIssuers;
+
+  @Override
+  public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
+    if (!initialized.get()) {
+      initLock.lock();
+      try {
+        if (aliasService == null) {
+          throw new ServiceLifecycleException("The required AliasService reference has not been set.");
+        }
+        try {
+          int maxIssuers = DEFAULT_MAX_TRUSTED_ISSUERS;
+          long cacheTtlSecs = KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS;
+          int connectTimeoutMs = KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS;
+          int readTimeoutMs = KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS;
+
+          if (config instanceof Configuration) {
+            final Configuration conf = (Configuration) config;
+            maxIssuers = conf.getInt(MAX_TRUSTED_ISSUERS_CONFIG, DEFAULT_MAX_TRUSTED_ISSUERS);
+            cacheTtlSecs = conf.getLong(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS,
+                KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS);
+            connectTimeoutMs = conf.getInt(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS,
+                KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS);
+            readTimeoutMs = conf.getInt(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS,
+                KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS);
+          }
+
+          this.maxTrustedIssuers = maxIssuers;
+          this.database = new TrustedOidcIssuerDatabase(
+              DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
+          this.discoveryHelper = new OIDCDiscoveryHelper(this, cacheTtlSecs,
+              OIDCDiscoveryHelper.buildHttpClient(connectTimeoutMs, readTimeoutMs));
+          reloadRegistrySnapshot();
+          initialized.set(true);
+        } catch (ServiceLifecycleException e) {
+          throw e;
+        } catch (Exception e) {
+          throw new ServiceLifecycleException("Error initializing JdbcTrustedOidcIssuerService: " + e, e);
+        }
+      } finally {
+        initLock.unlock();
+      }
+    }
+  }
+
+  @Override
+  public void start() throws ServiceLifecycleException {
+  }
+
+  @Override
+  public void stop() throws ServiceLifecycleException {
+  }
+
+  public void setAliasService(AliasService aliasService) {
+    this.aliasService = aliasService;
+  }
+
+  protected AliasService getAliasService() {
+    return aliasService;
+  }
+
+  @Override
+  public boolean isTrusted(String issuerUrl) {
+    return registrySnapshot.get().containsKey(issuerUrl);
+  }
+
+  @Override
+  public boolean isDynamicJwks(String issuerUrl) {
+    final TrustedOidcIssuer entry = registrySnapshot.get().get(issuerUrl);
+    return entry != null && entry.isDynamicJwks();
+  }
+
+  @Override
+  public Optional<String> resolveJwksUri(String issuerUrl) {
+    return discoveryHelper.discoverJwksUri(issuerUrl);
+  }
+
+  @Override
+  public synchronized void register(TrustedOidcIssuer issuer) {
+    if (registrySnapshot.get().size() >= maxTrustedIssuers) {
+      throw new IllegalStateException(
+          "Cannot register issuer: MAX_TRUSTED_ISSUERS (" + maxTrustedIssuers + ") reached");
+    }
+    try {
+      database.insert(issuer);
+    } catch (SQLException e) {
+      LOG.errorRegisteringIssuer(issuer.getIssuerUrl(), e.getMessage(), e);
+      throw new RuntimeException("Error registering trusted OIDC issuer: " + issuer.getIssuerUrl(), e);
+    }
+    reloadRegistrySnapshot();
+  }
+
+  @Override
+  public synchronized void deregister(String issuerUrl) {
+    try {
+      database.delete(issuerUrl);
+    } catch (SQLException e) {
+      LOG.errorDeregisteringIssuer(issuerUrl, e.getMessage(), e);
+      throw new RuntimeException("Error deregistering trusted OIDC issuer: " + issuerUrl, e);
+    }
+    reloadRegistrySnapshot();
+    discoveryHelper.invalidate(issuerUrl);
+  }
+
+  @Override
+  public void refreshJwksUri(String issuerUrl) {
+    if (isDynamicJwks(issuerUrl)) {
+      discoveryHelper.invalidate(issuerUrl);
+    }
+  }
+
+  @Override
+  public List<TrustedOidcIssuer> list() {
+    return new ArrayList<>(registrySnapshot.get().values());
+  }
+
+  /**
+   * Rebuilds the registry snapshot from the current DB state.
+   * Called on init, after register, and after deregister.
+   * Synchronized on this to prevent concurrent rebuilds from interleaving with mutations.
+   */
+  private synchronized void reloadRegistrySnapshot() {
+    try {
+      final Map<String, TrustedOidcIssuer> fresh = database.selectAll().stream()
+          .collect(Collectors.toMap(TrustedOidcIssuer::getIssuerUrl, Function.identity()));
+      registrySnapshot.set(Collections.unmodifiableMap(fresh));
+    } catch (Exception e) {
+      LOG.errorReloadingRegistrySnapshot(e.getMessage(), e);
+    }
+  }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java
new file mode 100644
index 000000000..2c830bebb
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+
+import java.net.URI;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Fetches and caches JWKS URIs resolved from OIDC provider discovery documents
+ * (/.well-known/openid-configuration). Backed by a Caffeine time-based cache.
+ * <p>
+ * SSRF gate: {@link #discoverJwksUri(String)} returns {@link Optional#empty()} immediately
+ * for any issuer not registered for dynamic JWKS. No HTTP call is ever made for
+ * untrusted or static-JWKS issuers.
+ * <p>
+ * The {@link CloseableHttpClient} is injected at construction time so that tests can
+ * supply a mock and verify the full fetch-and-parse code path without overriding methods.
+ * Production callers use {@link #buildHttpClient(int, int)} to obtain a properly
+ * configured long-lived client.
+ */
+class OIDCDiscoveryHelper {
+
+  private static final TrustedOidcIssuerServiceMessages LOG =
+      MessagesFactory.get(TrustedOidcIssuerServiceMessages.class);
+
+  private static final String USER_AGENT = "Apache-Knox-OIDCDiscovery/1.0";
+  private static final int HTTP_RETRY_COUNT = 2;
+  // Idle connections in the pool are closed after this duration so the next cache-miss
+  // fetch always goes through a fresh connection rather than a potentially stale one.
+  private static final long IDLE_EVICTION_SECONDS = 60L;
+
+  private final TrustedOidcIssuerService trustedIssuers;
+  // OIDC discovery document cache: issuerUrl → jwks_uri resolved from discovery endpoint.
+  // Entries expire after cacheTtlSeconds and are re-fetched lazily on the next access.
+  private final Cache<String, String> discoveryDocumentCache;
+  private final CloseableHttpClient httpClient;
+
+  /**
+   * Creates an {@code OIDCDiscoveryHelper} with the supplied HTTP client. Use
+   * {@link #buildHttpClient(int, int)} to obtain the production-configured client.
+   */
+  OIDCDiscoveryHelper(TrustedOidcIssuerService trustedIssuers, long cacheTtlSeconds,
+      CloseableHttpClient httpClient) {
+    this.trustedIssuers = trustedIssuers;
+    this.discoveryDocumentCache = Caffeine.newBuilder()
+        .expireAfterWrite(cacheTtlSeconds, TimeUnit.SECONDS)
+        .build();
+    this.httpClient = httpClient;
+  }
+
+  /**
+   * Builds a production-configured {@link CloseableHttpClient} for OIDC discovery fetches.
+   * <p>
+   * {@code requestSentRetryEnabled=true}: Discovery endpoints are GET-only (idempotent by RFC 7231
+   * §4.2.2), so retrying after the request was sent is safe and covers the most common
+   * failure mode — connection reset mid-response.
+   * <p>
+   * {@code evictIdleConnections} + {@code evictExpiredConnections}: the client is held for the
+   * gateway process lifetime. Without eviction, pooled connections become stale when the remote
+   * server or a network middlebox closes them silently, causing the next fetch to fail with a
+   * {@code NoHttpResponseException} before the retry handler can save it.
+   */
+  static CloseableHttpClient buildHttpClient(int connectTimeoutMs, int readTimeoutMs) {
+    final RequestConfig requestConfig = RequestConfig.custom()
+        .setConnectTimeout(connectTimeoutMs)
+        .setSocketTimeout(readTimeoutMs)
+        .build();
+    return HttpClients.custom()
+        .setDefaultRequestConfig(requestConfig)
+        .setRetryHandler(new DefaultHttpRequestRetryHandler(HTTP_RETRY_COUNT, true))
+        .evictIdleConnections(IDLE_EVICTION_SECONDS, TimeUnit.SECONDS)
+        .evictExpiredConnections()
+        .build();
+  }
+
+  /**
+   * Returns the JWKS URI for the given issuer URL, resolving it via OIDC discovery if
+   * not already cached. Returns {@link Optional#empty()} immediately without any HTTP
+   * call if the issuer is not registered for dynamic JWKS — this is the primary SSRF gate.
+   * <p>
+   * {@code Cache.get(key, mappingFunction)} is atomic per key: concurrent cache misses for the
+   * same issuer block on a single {@link #fetchJwksUri} call and share its result. If
+   * {@link #fetchJwksUri} returns null (on any error), Caffeine does not cache null, so the
+   * next call retries transparently.
+   */
+  Optional<String> discoverJwksUri(String issuerUrl) {
+    if (!trustedIssuers.isDynamicJwks(issuerUrl)) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(discoveryDocumentCache.get(issuerUrl, this::fetchJwksUri));
+  }
+
+  /**
+   * Evicts the cached JWKS URI for the given issuer so the next call to
+   * {@link #discoverJwksUri(String)} re-fetches from the discovery endpoint.
+   */
+  void invalidate(String issuerUrl) {
+    discoveryDocumentCache.invalidate(issuerUrl);
+  }
+
+  /**
+   * Fetches the JWKS URI by retrieving and parsing the OIDC discovery document for the
+   * given issuer. The discovery URL is constructed by stripping any trailing slash from
+   * the issuer URL and appending {@code /.well-known/openid-configuration}.
+   * Returns null on any error so Caffeine does not cache the failure and the next call retries.
+   */
+  String fetchJwksUri(String issuerUrl) {
+    final String discoveryUrl = issuerUrl.replaceAll("/$", "") + "/.well-known/openid-configuration";
+    final String body = httpGet(issuerUrl, discoveryUrl);
+    if (body == null) {
+      return null;
+    }
+    try {
+      final URI jwksUri = OIDCProviderMetadata.parse(body).getJWKSetURI();
+      if (jwksUri == null) {
+        // Defensive: OIDC spec requires jwks_uri; Nimbus 11.x throws ParseException if absent,
+        // but a non-compliant or future-lenient implementation could return null here.
+        LOG.errorParsingDiscoveryDocument(issuerUrl,
+            "discovery document contains no jwks_uri", null);
+        return null;
+      }
+      return jwksUri.toString();
+    } catch (Exception e) {
+      LOG.errorParsingDiscoveryDocument(issuerUrl, e.getMessage(), e);
+      return null;
+    }
+  }
+
+  /**
+   * Executes a GET request against the given URL and returns the response body as a string.
+   * Logs any failure and returns null so the caller knows not to cache the result.
+   */
+  private String httpGet(String issuerUrl, String url) {
+    final HttpGet request = new HttpGet(url);
+    request.setHeader("User-Agent", USER_AGENT);
+    try (CloseableHttpResponse response = httpClient.execute(request)) {
+      final int statusCode = response.getStatusLine().getStatusCode();
+      if (statusCode != 200) {
+        LOG.errorFetchingDiscoveryDocument(issuerUrl, url, "HTTP " + statusCode,
+            new java.io.IOException("Non-200 status: " + statusCode));
+        return null;
+      }
+      return EntityUtils.toString(response.getEntity());
+    } catch (Exception e) {
+      LOG.errorFetchingDiscoveryDocument(issuerUrl, url, e.getMessage(), e);
+      return null;
+    }
+  }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
new file mode 100644
index 000000000..92d0fd5c5
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.database.KnoxDatabase;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * JDBC helper for the {@code TRUSTED_OIDC_ISSUERS} table.
+ * All SQL uses {@link PreparedStatement} with {@code ?} parameters only.
+ * Uses {@link ResultSet#getBoolean(String)} for the {@code dynamic_jwks} column,
+ * which correctly maps both BOOLEAN (standard/Derby) and NUMBER(1) (Oracle) values.
+ */
+class TrustedOidcIssuerDatabase extends KnoxDatabase {
+
+  static final String TABLE_NAME = "TRUSTED_OIDC_ISSUERS";
+
+  private static final String INSERT_SQL =
+      "INSERT INTO " + TABLE_NAME + " (issuer_url, dynamic_jwks, cluster_name, registered_at, registered_by) VALUES (?, ?, ?, ?, ?)";
+  private static final String DELETE_SQL =
+      "DELETE FROM " + TABLE_NAME + " WHERE issuer_url = ?";
+  private static final String SELECT_ALL_SQL =
+      "SELECT issuer_url, dynamic_jwks, cluster_name, registered_at, registered_by FROM " + TABLE_NAME;
+  private static final String COUNT_SQL =
+      "SELECT COUNT(*) FROM " + TABLE_NAME;
+
+  TrustedOidcIssuerDatabase(DataSource dataSource, String dbType) throws Exception {
+    super(dataSource);
+    final DatabaseType databaseType = DatabaseType.fromString(dbType);
+    createTableIfNotExists(TABLE_NAME, databaseType.trustedOidcIssuersTableSql());
+  }
+
+  void insert(TrustedOidcIssuer issuer) throws SQLException {
+    try (Connection connection = dataSource.getConnection();
+         PreparedStatement ps = connection.prepareStatement(INSERT_SQL)) {
+      ps.setString(1, issuer.getIssuerUrl());
+      ps.setBoolean(2, issuer.isDynamicJwks());
+      ps.setString(3, issuer.getClusterName());
+      ps.setTimestamp(4, Timestamp.from(issuer.getRegisteredAt()));
+      ps.setString(5, issuer.getRegisteredBy());
+      ps.executeUpdate();
+    }
+  }
+
+  void delete(String issuerUrl) throws SQLException {
+    try (Connection connection = dataSource.getConnection();
+         PreparedStatement ps = connection.prepareStatement(DELETE_SQL)) {
+      ps.setString(1, issuerUrl);
+      ps.executeUpdate();
+    }
+  }
+
+  List<TrustedOidcIssuer> selectAll() throws SQLException {
+    final List<TrustedOidcIssuer> result = new ArrayList<>();
+    try (Connection connection = dataSource.getConnection();
+         PreparedStatement ps = connection.prepareStatement(SELECT_ALL_SQL);
+         ResultSet rs = ps.executeQuery()) {
+      while (rs.next()) {
+        result.add(new TrustedOidcIssuer(
+            rs.getString("issuer_url"),
+            rs.getBoolean("dynamic_jwks"),
+            rs.getString("cluster_name"),
+            rs.getTimestamp("registered_at").toInstant(),
+            rs.getString("registered_by")
+        ));
+      }
+    }
+    return result;
+  }
+
+  int count() throws SQLException {
+    try (Connection connection = dataSource.getConnection();
+         PreparedStatement ps = connection.prepareStatement(COUNT_SQL);
+         ResultSet rs = ps.executeQuery()) {
+      return rs.next() ? rs.getInt(1) : 0;
+    }
+  }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java
new file mode 100644
index 000000000..1918bec47
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.i18n.messages.Message;
+import org.apache.knox.gateway.i18n.messages.MessageLevel;
+import org.apache.knox.gateway.i18n.messages.Messages;
+import org.apache.knox.gateway.i18n.messages.StackTrace;
+
+@Messages(logger = "org.apache.knox.gateway.knoxidf.trustedoidcissuer.service")
+interface TrustedOidcIssuerServiceMessages {
+
+  @Message(level = MessageLevel.ERROR,
+      text = "Failed to fetch OIDC discovery document for issuer {0} from {1}: {2}")
+  void errorFetchingDiscoveryDocument(String issuerUrl, String discoveryUrl, String cause,
+      @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR,
+      text = "Failed to parse OIDC discovery document for issuer {0}: {1}")
+  void errorParsingDiscoveryDocument(String issuerUrl, String cause,
+      @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR,
+      text = "Error registering trusted OIDC issuer {0}: {1}")
+  void errorRegisteringIssuer(String issuerUrl, String cause,
+      @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR,
+      text = "Error deregistering trusted OIDC issuer {0}: {1}")
+  void errorDeregisteringIssuer(String issuerUrl, String cause,
+      @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR,
+      text = "Error reloading trusted OIDC issuer registry snapshot: {0}")
+  void errorReloadingRegistrySnapshot(String cause,
+      @StackTrace(level = MessageLevel.DEBUG) Exception e);
+}
diff --git a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
index 93bc7845a..e67206f2c 100644
--- a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
+++ b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
@@ -38,3 +38,4 @@ org.apache.knox.gateway.services.factory.TopologyServiceFactory
 org.apache.knox.gateway.services.factory.LdapServiceFactory
 org.apache.knox.gateway.services.factory.LDAPRolesLookupServiceFactory
 org.apache.knox.gateway.services.factory.TokenServiceFactory
+org.apache.knox.gateway.services.factory.TrustedOidcIssuerServiceFactory
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java
new file mode 100644
index 000000000..c606010a5
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.junit.Test;
+
+import java.time.Instant;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class EmptyTrustedOidcIssuerServiceTest {
+
+  private final EmptyTrustedOidcIssuerService service = new EmptyTrustedOidcIssuerService();
+
+  @Test
+  public void testIsTrustedReturnsFalse() {
+    assertFalse(service.isTrusted("https://any.issuer.com"));
+  }
+
+  @Test
+  public void testIsDynamicJwksReturnsFalse() {
+    assertFalse(service.isDynamicJwks("https://any.issuer.com"));
+  }
+
+  @Test
+  public void testResolveJwksUriReturnsEmpty() {
+    assertFalse(service.resolveJwksUri("https://any.issuer.com").isPresent());
+  }
+
+  @Test
+  public void testRefreshJwksUriIsNoOp() {
+    service.refreshJwksUri("https://any.issuer.com"); // must not throw
+  }
+
+  @Test
+  public void testListReturnsEmpty() {
+    assertTrue(service.list().isEmpty());
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void testRegisterThrows() {
+    service.register(new TrustedOidcIssuer("https://issuer.com", false, null, Instant.now(), null));
+  }
+
+  @Test(expected = UnsupportedOperationException.class)
+  public void testDeregisterThrows() {
+    service.deregister("https://issuer.com");
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
new file mode 100644
index 000000000..03bc54f76
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
@@ -0,0 +1,325 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.AbstractDataSourceFactory;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.easymock.EasyMock;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class JdbcTrustedOidcIssuerServiceTest {
+
+  private static final String DB_NAME = "trustedissuers_svc_test";
+  private static final String DERBY_CREATE_URL = "jdbc:derby:memory:" + DB_NAME + ";create=true";
+  private static final String DERBY_URL = "jdbc:derby:memory:" + DB_NAME;
+  private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" + DB_NAME + ";shutdown=true";
+
+  private static GatewayConfig gatewayConfig;
+  private static AliasService aliasService;
+
+  private JdbcTrustedOidcIssuerService service;
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    // Derby 10.14 does not recognize locales like en_001; force a standard locale.
+    java.util.Locale.setDefault(java.util.Locale.US);
+    // Create the Derby in-memory DB so DerbyDataSourceFactory can connect to it
+    DriverManager.getConnection(DERBY_CREATE_URL).close();
+
+    gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(gatewayConfig.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(gatewayConfig.getDatabaseName()).andReturn("memory:" + DB_NAME).anyTimes();
+    EasyMock.replay(gatewayConfig);
+
+    aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
+        AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
+    EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
+        AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
+    EasyMock.replay(aliasService);
+  }
+
+  @AfterClass
+  public static void tearDownClass() {
+    try {
+      DriverManager.getConnection(DERBY_SHUTDOWN_URL);
+    } catch (SQLException e) {
+      // Derby signals a successful in-memory shutdown as SQLState 08006 / error 45000
+      if (!(e.getErrorCode() == 45000 && "08006".equals(e.getSQLState()))) {
+        throw new RuntimeException("Unexpected Derby shutdown error", e);
+      }
+    }
+  }
+
+  @Before
+  public void setUp() throws ServiceLifecycleException, SQLException {
+    // Clear table between tests
+    try (Connection conn = DriverManager.getConnection(DERBY_URL);
+         PreparedStatement ps = conn.prepareStatement("DELETE FROM TRUSTED_OIDC_ISSUERS")) {
+      ps.executeUpdate();
+    } catch (SQLException e) {
+      // Table may not exist yet on first setUp; service.init() will create it
+    }
+
+    service = new JdbcTrustedOidcIssuerService();
+    service.setAliasService(aliasService);
+    service.init(gatewayConfig, null);
+  }
+
+  // ------------------------------------------------------------------
+  // Basic CRUD and snapshot
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testRegisterAndIsTrusted() {
+    service.register(issuer("https://issuer.example.com", false));
+
+    assertTrue(service.isTrusted("https://issuer.example.com"));
+    assertFalse(service.isTrusted("https://other.example.com"));
+  }
+
+  @Test
+  public void testDeregisterClearsSnapshot() {
+    service.register(issuer("https://issuer.example.com", false));
+    assertTrue(service.isTrusted("https://issuer.example.com"));
+
+    service.deregister("https://issuer.example.com");
+    assertFalse(service.isTrusted("https://issuer.example.com"));
+  }
+
+  @Test
+  public void testListReflectsSnapshot() {
+    final TrustedOidcIssuer a = issuer("https://a.example.com", false, "clusterA", "admin");
+    final TrustedOidcIssuer b = issuer("https://b.example.com", true, "clusterB", "operator");
+    service.register(a);
+    service.register(b);
+
+    final List<TrustedOidcIssuer> listed = service.list();
+    assertEquals(2, listed.size());
+    assertIssuerInList(a, listed);
+    assertIssuerInList(b, listed);
+  }
+
+  @Test
+  public void testDynamicJwksFlag() {
+    service.register(issuer("https://static.example.com", false));
+    service.register(issuer("https://dynamic.example.com", true));
+
+    assertFalse(service.isDynamicJwks("https://static.example.com"));
+    assertTrue(service.isDynamicJwks("https://dynamic.example.com"));
+    assertFalse("Unregistered issuer must return false",
+        service.isDynamicJwks("https://unknown.example.com"));
+  }
+
+  /**
+   * All fields must round-trip through the DB correctly, including nullable ones.
+   */
+  @Test
+  public void testRegisterPersistsAllFields() {
+    final TrustedOidcIssuer issuer = issuer("https://issuer.example.com", true, "prod-cluster", "admin");
+    service.register(issuer);
+
+    final List<TrustedOidcIssuer> listed = service.list();
+    assertEquals(1, listed.size());
+    assertIssuerEquals(issuer, listed.get(0));
+  }
+
+  @Test
+  public void testRegisterPersistsNullableFieldsAsNull() {
+    // clusterName and registeredBy may be null
+    final TrustedOidcIssuer issuer = new TrustedOidcIssuer(
+        "https://issuer.example.com", false, null, Instant.now(), null);
+    service.register(issuer);
+
+    final TrustedOidcIssuer fromList = service.list().get(0);
+    assertEquals("https://issuer.example.com", fromList.getIssuerUrl());
+    assertFalse(fromList.isDynamicJwks());
+    assertNotNull("registeredAt must always be persisted", fromList.getRegisteredAt());
+    assertTrue("clusterName round-trips as null", fromList.getClusterName() == null
+        || fromList.getClusterName().isEmpty());
+    assertTrue("registeredBy round-trips as null", fromList.getRegisteredBy() == null
+        || fromList.getRegisteredBy().isEmpty());
+  }
+
+  @Test
+  public void testRegistrySnapshotWarmOnInit() throws Exception {
+    // Pre-populate the TRUSTED_OIDC_ISSUERS table before initializing a new service
+    final String preloadedUrl = "https://preloaded.example.com";
+    try (Connection conn = DriverManager.getConnection(DERBY_URL);
+         PreparedStatement ps = conn.prepareStatement(
+             "INSERT INTO TRUSTED_OIDC_ISSUERS (issuer_url, dynamic_jwks, registered_at) "
+                 + "VALUES (?, ?, ?)")) {
+      ps.setString(1, preloadedUrl);
+      ps.setBoolean(2, false);
+      ps.setTimestamp(3, java.sql.Timestamp.from(Instant.now()));
+      ps.executeUpdate();
+    }
+
+    // New service instance: snapshot must be loaded from DB on startup
+    final JdbcTrustedOidcIssuerService freshService = new JdbcTrustedOidcIssuerService();
+    freshService.setAliasService(aliasService);
+    freshService.init(gatewayConfig, null);
+
+    assertTrue("Pre-populated issuer must be trusted after init", freshService.isTrusted(preloadedUrl));
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testDuplicateRegistrationThrows() {
+    final TrustedOidcIssuer issuer = issuer("https://issuer.example.com", false);
+    service.register(issuer);
+    service.register(issuer); // duplicate primary key → RuntimeException
+  }
+
+  @Test
+  public void testReloadAfterMutation() {
+    final String url = "https://issuer.example.com";
+
+    service.register(issuer(url, false));
+    assertTrue("Snapshot must contain issuer after register", service.isTrusted(url));
+    assertEquals(1, service.list().size());
+
+    service.deregister(url);
+    assertFalse("Snapshot must not contain issuer after deregister", service.isTrusted(url));
+    assertTrue(service.list().isEmpty());
+  }
+
+  @Test
+  public void testMaxTrustedIssuers() throws ServiceLifecycleException {
+    final GatewayConfigImpl limitedConfig = new GatewayConfigImpl();
+    limitedConfig.set(JdbcTrustedOidcIssuerService.MAX_TRUSTED_ISSUERS_CONFIG, "2");
+    limitedConfig.set(GatewayConfigImpl.GATEWAY_DATABASE_TYPE, DatabaseType.DERBY.type());
+    limitedConfig.set(GatewayConfigImpl.GATEWAY_DATABASE_NAME, "memory:" + DB_NAME);
+
+    final JdbcTrustedOidcIssuerService limitedService = new JdbcTrustedOidcIssuerService();
+    limitedService.setAliasService(aliasService);
+    limitedService.init(limitedConfig, null);
+
+    limitedService.register(issuer("https://a.example.com", false));
+    assertEquals("First registration must succeed", 1, limitedService.list().size());
+
+    limitedService.register(issuer("https://b.example.com", false));
+    assertEquals("Second registration must succeed", 2, limitedService.list().size());
+
+    try {
+      limitedService.register(issuer("https://c.example.com", false));
+      fail("Expected IllegalStateException when exceeding max issuers limit");
+    } catch (IllegalStateException e) {
+      assertEquals("Prior registrations must be unaffected by the rejected call",
+          2, limitedService.list().size());
+    }
+  }
+
+  @Test
+  public void testDeregisterNonExistentIsNoOp() {
+    // deregister of unknown issuer must not throw
+    service.deregister("https://nonexistent.example.com");
+    assertTrue(service.list().isEmpty());
+  }
+
+  // ------------------------------------------------------------------
+  // resolveJwksUri / refreshJwksUri delegation
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testResolveJwksUriForNonDynamicIssuerReturnsEmpty() {
+    service.register(issuer("https://static.example.com", false));
+    // Non-dynamic issuer: OIDCDiscoveryHelper.discoverJwksUri returns empty immediately
+    // without any HTTP call (the SSRF gate inside the helper blocks it).
+    assertFalse(service.resolveJwksUri("https://static.example.com").isPresent());
+  }
+
+  @Test
+  public void testResolveJwksUriForUnregisteredIssuerReturnsEmpty() {
+    assertFalse(service.resolveJwksUri("https://unknown.example.com").isPresent());
+  }
+
+  @Test
+  public void testRefreshJwksUriForNonDynamicIsNoOp() {
+    service.register(issuer("https://static.example.com", false));
+    // refreshJwksUri checks isDynamicJwks first; for non-dynamic it is a no-op
+    service.refreshJwksUri("https://static.example.com"); // must not throw
+  }
+
+  @Test
+  public void testRefreshJwksUriForUnregisteredIsNoOp() {
+    service.refreshJwksUri("https://unknown.example.com"); // must not throw
+  }
+
+  // ------------------------------------------------------------------
+  // Init guard
+  // ------------------------------------------------------------------
+
+  @Test(expected = ServiceLifecycleException.class)
+  public void testInitFailsWithoutAliasService() throws ServiceLifecycleException {
+    final JdbcTrustedOidcIssuerService noAliasService = new JdbcTrustedOidcIssuerService();
+    // setAliasService NOT called
+    noAliasService.init(gatewayConfig, null);
+  }
+
+  // ------------------------------------------------------------------
+  // Helpers
+  // ------------------------------------------------------------------
+
+  private static TrustedOidcIssuer issuer(String url, boolean dynamicJwks) {
+    return new TrustedOidcIssuer(url, dynamicJwks, null, Instant.now(), null);
+  }
+
+  private static TrustedOidcIssuer issuer(String url, boolean dynamicJwks,
+      String clusterName, String registeredBy) {
+    return new TrustedOidcIssuer(url, dynamicJwks, clusterName, Instant.now(), registeredBy);
+  }
+
+  /**
+   * Asserts that all non-generated fields of {@code expected} match {@code actual}, and
+   * that the generated {@code registeredAt} field is non-null.
+   */
+  private static void assertIssuerEquals(TrustedOidcIssuer expected, TrustedOidcIssuer actual) {
+    assertEquals("issuerUrl", expected.getIssuerUrl(), actual.getIssuerUrl());
+    assertEquals("dynamicJwks", expected.isDynamicJwks(), actual.isDynamicJwks());
+    assertEquals("clusterName", expected.getClusterName(), actual.getClusterName());
+    assertEquals("registeredBy", expected.getRegisteredBy(), actual.getRegisteredBy());
+    assertNotNull("registeredAt must be persisted", actual.getRegisteredAt());
+  }
+
+  private static void assertIssuerInList(TrustedOidcIssuer expected, List<TrustedOidcIssuer> list) {
+    final TrustedOidcIssuer found = list.stream()
+        .filter(i -> expected.getIssuerUrl().equals(i.getIssuerUrl()))
+        .findFirst()
+        .orElseThrow(() -> new AssertionError("Issuer not found in list: " + expected.getIssuerUrl()));
+    assertIssuerEquals(expected, found);
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java
new file mode 100644
index 000000000..f978a38af
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.http.StatusLine;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class OIDCDiscoveryHelperTest {
+
+  private static final String ISSUER = "https://issuer.example.com";
+  private static final String ISSUER_WITH_SLASH = "https://issuer.example.com/";
+  private static final String JWKS_URI = "https://issuer.example.com/jwks";
+  private static final long CACHE_TTL = 600L;
+
+  // Minimal valid OIDC discovery document (all required fields per OpenID Connect Discovery 1.0)
+  private static final String VALID_DISCOVERY_JSON = "{"
+      + "\"issuer\":\"" + ISSUER + "\","
+      + "\"authorization_endpoint\":\"https://issuer.example.com/authorize\","
+      + "\"jwks_uri\":\"" + JWKS_URI + "\","
+      + "\"response_types_supported\":[\"code\"],"
+      + "\"subject_types_supported\":[\"public\"],"
+      + "\"id_token_signing_alg_values_supported\":[\"RS256\"]"
+      + "}";
+
+  // Discovery doc where jwks_uri is absent; Nimbus throws ParseException for this.
+  private static final String DISCOVERY_JSON_NO_JWKS_URI = "{"
+      + "\"issuer\":\"" + ISSUER + "\","
+      + "\"authorization_endpoint\":\"https://issuer.example.com/authorize\","
+      + "\"response_types_supported\":[\"code\"],"
+      + "\"subject_types_supported\":[\"public\"],"
+      + "\"id_token_signing_alg_values_supported\":[\"RS256\"]"
+      + "}";
+
+  // ------------------------------------------------------------------
+  // Helpers
+  // ------------------------------------------------------------------
+
+  /** Returns a mock TrustedOidcIssuerService with fixed isTrusted / isDynamicJwks behavior. */
+  private static TrustedOidcIssuerService trustedDynamic() {
+    return stubService(true, true);
+  }
+
+  private static TrustedOidcIssuerService trustedStatic() {
+    return stubService(true, false);
+  }
+
+  private static TrustedOidcIssuerService untrusted() {
+    return stubService(false, false);
+  }
+
+  private static TrustedOidcIssuerService stubService(boolean trusted, boolean dynamicJwks) {
+    return new EmptyTrustedOidcIssuerService() {
+      @Override public boolean isTrusted(String url) { return trusted; }
+      @Override public boolean isDynamicJwks(String url) { return dynamicJwks; }
+    };
+  }
+
+  /**
+   * Returns a mock CloseableHttpResponse that yields the given status code and body.
+   * Uses a real StringEntity so EntityUtils.toString() works without deep mocking.
+   */
+  private static CloseableHttpResponse mockResponse(int statusCode, String body) throws Exception {
+    final StatusLine statusLine = EasyMock.createNiceMock(StatusLine.class);
+    EasyMock.expect(statusLine.getStatusCode()).andReturn(statusCode).anyTimes();
+    EasyMock.replay(statusLine);
+
+    final CloseableHttpResponse response = EasyMock.createNiceMock(CloseableHttpResponse.class);
+    EasyMock.expect(response.getStatusLine()).andReturn(statusLine).anyTimes();
+    if (body != null) {
+      EasyMock.expect(response.getEntity()).andReturn(new StringEntity(body, "UTF-8")).anyTimes();
+    }
+    EasyMock.replay(response);
+    return response;
+  }
+
+  /** Returns an OIDCDiscoveryHelper backed by the given mock HttpClient. */
+  private static OIDCDiscoveryHelper helper(TrustedOidcIssuerService trustedIssuers,
+      CloseableHttpClient client) {
+    return new OIDCDiscoveryHelper(trustedIssuers, CACHE_TTL, client);
+  }
+
+  // ------------------------------------------------------------------
+  // SSRF gate
+  // ------------------------------------------------------------------
+
+  /**
+   * SSRF prevention: discoverJwksUri must return empty immediately for an issuer that is
+   * not registered for dynamic JWKS and must never call HttpClient.execute.
+   */
+  @Test
+  public void testNoHttpCallForUntrustedIssuer() throws Exception {
+    // Strict mock: any unexpected call to execute() fails the test immediately.
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.replay(client);
+
+    final Optional<String> result = helper(untrusted(), client).discoverJwksUri(ISSUER);
+
+    assertFalse("Untrusted issuer must return empty", result.isPresent());
+    EasyMock.verify(client); // verifies execute() was never called
+  }
+
+  @Test
+  public void testStaticJwksIssuerMakesNoHttpCall() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.replay(client);
+
+    final Optional<String> result = helper(trustedStatic(), client).discoverJwksUri(ISSUER);
+
+    assertFalse("Static-JWKS issuer must return empty", result.isPresent());
+    EasyMock.verify(client);
+  }
+
+  // ------------------------------------------------------------------
+  // Happy path
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testDiscoveryReturnsJwksUri() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+    EasyMock.replay(client);
+
+    final Optional<String> result = helper(trustedDynamic(), client).discoverJwksUri(ISSUER);
+
+    assertTrue(result.isPresent());
+    assertEquals(JWKS_URI, result.get());
+    EasyMock.verify(client);
+  }
+
+  // ------------------------------------------------------------------
+  // URL normalization
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testDiscoveryUrlTrailingSlashStripped() throws Exception {
+    final Capture<HttpUriRequest> captured = EasyMock.newCapture();
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.capture(captured)))
+        .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+    EasyMock.replay(client);
+
+    helper(trustedDynamic(), client).discoverJwksUri(ISSUER_WITH_SLASH);
+
+    assertEquals("https://issuer.example.com/.well-known/openid-configuration",
+        captured.getValue().getURI().toString());
+  }
+
+  @Test
+  public void testDiscoveryUrlNoDoubleSlashWithoutTrailingSlash() throws Exception {
+    final Capture<HttpUriRequest> captured = EasyMock.newCapture();
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.capture(captured)))
+        .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+    EasyMock.replay(client);
+
+    helper(trustedDynamic(), client).discoverJwksUri(ISSUER);
+
+    assertEquals("https://issuer.example.com/.well-known/openid-configuration",
+        captured.getValue().getURI().toString());
+  }
+
+  // ------------------------------------------------------------------
+  // Cache behaviour
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testDiscoveryDocumentCacheHit() throws Exception {
+    // Strict mock expects exactly one execute() call; a second call would throw.
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(200, VALID_DISCOVERY_JSON))
+        .once();
+    EasyMock.replay(client);
+
+    final OIDCDiscoveryHelper h = helper(trustedDynamic(), client);
+    h.discoverJwksUri(ISSUER); // fetch
+    h.discoverJwksUri(ISSUER); // cache hit — must NOT call execute again
+
+    EasyMock.verify(client);
+  }
+
+  @Test
+  public void testInvalidateEvictsFromCache() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(200, VALID_DISCOVERY_JSON))
+        .times(2); // must be called twice after eviction
+    EasyMock.replay(client);
+
+    final OIDCDiscoveryHelper h = helper(trustedDynamic(), client);
+    h.discoverJwksUri(ISSUER); // fetch #1
+    h.invalidate(ISSUER);      // evict
+    h.discoverJwksUri(ISSUER); // fetch #2
+
+    EasyMock.verify(client);
+  }
+
+  /**
+   * When fetchJwksUri returns null (any failure), Caffeine must NOT cache the null.
+   * The next call must trigger a fresh HTTP request.
+   */
+  @Test
+  public void testNullNotCachedAfterFailure() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    // First call: connection error → fetchJwksUri returns null
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andThrow(new IOException("connection refused"));
+    // Second call: succeeds
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(200, VALID_DISCOVERY_JSON));
+    EasyMock.replay(client);
+
+    final OIDCDiscoveryHelper h = helper(trustedDynamic(), client);
+    assertFalse(h.discoverJwksUri(ISSUER).isPresent()); // failure → empty
+    assertTrue(h.discoverJwksUri(ISSUER).isPresent());  // retry → success
+
+    EasyMock.verify(client);
+  }
+
+  // ------------------------------------------------------------------
+  // HTTP error paths
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testHttpGetNon200ReturnsEmpty() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(404, null));
+    EasyMock.replay(client);
+
+    assertFalse(helper(trustedDynamic(), client).discoverJwksUri(ISSUER).isPresent());
+    EasyMock.verify(client);
+  }
+
+  @Test
+  public void testHttpGetConnectionExceptionReturnsEmpty() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andThrow(new IOException("connection refused"));
+    EasyMock.replay(client);
+
+    assertFalse(helper(trustedDynamic(), client).discoverJwksUri(ISSUER).isPresent());
+    EasyMock.verify(client);
+  }
+
+  // ------------------------------------------------------------------
+  // Discovery document parse errors
+  // ------------------------------------------------------------------
+
+  @Test
+  public void testMalformedDiscoveryDocumentReturnsEmpty() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(200, "not valid json at all"));
+    EasyMock.replay(client);
+
+    assertFalse(helper(trustedDynamic(), client).discoverJwksUri(ISSUER).isPresent());
+    EasyMock.verify(client);
+  }
+
+  /**
+   * Nimbus 11.x treats jwks_uri as required and throws ParseException when it is absent.
+   * Verifies the catch block in fetchJwksUri handles this and returns Optional.empty().
+   */
+  @Test
+  public void testMissingJwksUriReturnsEmpty() throws Exception {
+    final CloseableHttpClient client = EasyMock.createMock(CloseableHttpClient.class);
+    EasyMock.expect(client.execute(EasyMock.isA(HttpUriRequest.class)))
+        .andReturn(mockResponse(200, DISCOVERY_JSON_NO_JWKS_URI));
+    EasyMock.replay(client);
+
+    assertFalse(helper(trustedDynamic(), client).discoverJwksUri(ISSUER).isPresent());
+    EasyMock.verify(client);
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
new file mode 100644
index 000000000..be885e1ef
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.AbstractDataSourceFactory;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.factory.TrustedOidcIssuerServiceFactory;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+import org.easymock.EasyMock;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class TrustedOidcIssuerServiceFactoryTest {
+
+  private static final String DB_NAME = "trustedissuers_factory_test";
+  private static final String DERBY_CREATE_URL = "jdbc:derby:memory:" + DB_NAME + ";create=true";
+  private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" + DB_NAME + ";shutdown=true";
+
+  @BeforeClass
+  public static void setUpClass() throws SQLException {
+    // Derby 10.14 does not recognize locales like en_001; force a standard locale.
+    java.util.Locale.setDefault(java.util.Locale.US);
+    DriverManager.getConnection(DERBY_CREATE_URL).close();
+  }
+
+  @AfterClass
+  public static void tearDownClass() {
+    try {
+      DriverManager.getConnection(DERBY_SHUTDOWN_URL);
+    } catch (SQLException e) {
+      if (!(e.getErrorCode() == 45000 && "08006".equals(e.getSQLState()))) {
+        throw new RuntimeException("Unexpected Derby shutdown error", e);
+      }
+    }
+  }
+
+  // ------------------------------------------------------------------
+  // Empty (no KNOXIDF) cases
+  // ------------------------------------------------------------------
+
+  /** Zero topologies → no topology service returns anything → Empty. */
+  @Test
+  public void testNoTopologiesReturnsEmpty() throws Exception {
+    assertIsEmpty(createFactory(), buildEmptyGatewayServices(), emptyConfig());
+  }
+
+  /** Topologies exist but none contain KNOXIDF or KNOXIDF_ADMIN → Empty. */
+  @Test
+  public void testTopologiesWithNonKnoxIdfRolesReturnsEmpty() throws Exception {
+    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("HDFS", "WEBHDFS"), null);
+    assertIsEmpty(createFactory(), gws, emptyConfig());
+  }
+
+  /** TopologyService is null (not yet registered) → Empty, no NPE. */
+  @Test
+  public void testNullTopologyServiceReturnsEmpty() throws Exception {
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(null).anyTimes();
+    EasyMock.replay(gws);
+    assertIsEmpty(createFactory(), gws, emptyConfig());
+  }
+
+  // ------------------------------------------------------------------
+  // JDBC cases
+  // ------------------------------------------------------------------
+
+  /** A single KNOXIDF topology → JDBC. */
+  @Test
+  public void testKnoxIdfTopologyReturnsJdbc() throws Exception {
+    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("KNOXIDF"), derbyAlias());
+    assertIsJdbc(createFactory(), gws, derbyConfig());
+  }
+
+  /** A single KNOXIDF_ADMIN-only topology (no KNOXIDF) → JDBC. */
+  @Test
+  public void testKnoxIdfAdminOnlyTopologyReturnsJdbc() throws Exception {
+    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("KNOXIDF_ADMIN"), derbyAlias());
+    assertIsJdbc(createFactory(), gws, derbyConfig());
+  }
+
+  /** Both KNOXIDF and KNOXIDF_ADMIN in the same topology → JDBC. */
+  @Test
+  public void testBothRolesInSameTopologyReturnsJdbc() throws Exception {
+    final GatewayServices gws = buildGatewayServicesWithTopology(
+        withRoles("KNOXIDF", "KNOXIDF_ADMIN"), derbyAlias());
+    assertIsJdbc(createFactory(), gws, derbyConfig());
+  }
+
+  /** Multiple topologies; only the second has KNOXIDF → JDBC (verifies the loop continues). */
+  @Test
+  public void testMultipleTopologiesOneHasKnoxIdfReturnsJdbc() throws Exception {
+    final AliasService alias = derbyAlias();
+    final GatewayServices gws = buildGatewayServicesWithMultipleTopologies(
+        withRoles("HDFS", "WEBHDFS"), withRoles("KNOXIDF"), alias);
+    assertIsJdbc(createFactory(), gws, derbyConfig());
+  }
+
+  // ------------------------------------------------------------------
+  // Error handling
+  // ------------------------------------------------------------------
+
+  /**
+   * When JDBC service initialization fails (e.g. bad DB type), the factory must fall back
+   * to EmptyTrustedOidcIssuerService rather than propagating the exception.
+   */
+  @Test
+  public void testJdbcInitFailureFallsBackToEmpty() throws Exception {
+    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("KNOXIDF"), derbyAlias());
+
+    final GatewayConfig brokenConfig = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(brokenConfig.getDatabaseType()).andReturn("invalid_db_type").anyTimes();
+    EasyMock.expect(brokenConfig.getServiceParameter(EasyMock.anyString(), EasyMock.anyString()))
+        .andReturn("").anyTimes();
+    EasyMock.replay(brokenConfig);
+
+    assertIsEmpty(createFactory(), gws, brokenConfig);
+  }
+
+  // ------------------------------------------------------------------
+  // Helpers
+  // ------------------------------------------------------------------
+
+  private static TrustedOidcIssuerServiceFactory createFactory() {
+    return new TrustedOidcIssuerServiceFactory();
+  }
+
+  private static void assertIsEmpty(TrustedOidcIssuerServiceFactory factory,
+      GatewayServices gws, GatewayConfig config) throws Exception {
+    final org.apache.knox.gateway.services.Service result =
+        factory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, Map.of());
+    assertNotNull(result);
+    assertTrue("Expected EmptyTrustedOidcIssuerService but got " + result.getClass().getSimpleName(),
+        result instanceof EmptyTrustedOidcIssuerService);
+  }
+
+  private static void assertIsJdbc(TrustedOidcIssuerServiceFactory factory,
+      GatewayServices gws, GatewayConfig config) throws Exception {
+    final org.apache.knox.gateway.services.Service result =
+        factory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, Map.of());
+    assertNotNull(result);
+    assertTrue("Expected JdbcTrustedOidcIssuerService but got " + result.getClass().getSimpleName(),
+        result instanceof JdbcTrustedOidcIssuerService);
+  }
+
+  /** GatewayServices with a TopologyService returning no topologies; no AliasService needed. */
+  private static GatewayServices buildEmptyGatewayServices() {
+    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
+    EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.emptyList()).anyTimes();
+    EasyMock.replay(topologyService);
+
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE))
+        .andReturn(topologyService).anyTimes();
+    EasyMock.replay(gws);
+    return gws;
+  }
+
+  /**
+   * Builds a {@link GatewayServices} mock with one topology that has the given service roles.
+   * {@code alias} may be null when no JDBC init will be attempted.
+   */
+  private static GatewayServices buildGatewayServicesWithTopology(
+      String[] roles, AliasService alias) throws Exception {
+    final Topology topology = topologyWithRoles(roles);
+    return buildGatewayServices(Collections.singletonList(topology), alias);
+  }
+
+  private static GatewayServices buildGatewayServicesWithMultipleTopologies(
+      String[] roles1, String[] roles2, AliasService alias) throws Exception {
+    return buildGatewayServices(
+        Arrays.asList(topologyWithRoles(roles1), topologyWithRoles(roles2)), alias);
+  }
+
+  private static GatewayServices buildGatewayServices(
+      java.util.List<Topology> topologies, AliasService alias) throws Exception {
+    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
+    EasyMock.expect(topologyService.getTopologies()).andReturn(topologies).anyTimes();
+    EasyMock.replay(topologyService);
+
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE))
+        .andReturn(topologyService).anyTimes();
+    if (alias != null) {
+      EasyMock.expect(gws.getService(ServiceType.ALIAS_SERVICE))
+          .andReturn(alias).anyTimes();
+    }
+    EasyMock.replay(gws);
+    return gws;
+  }
+
+  private static Topology topologyWithRoles(String... roles) {
+    final Topology topology = EasyMock.createNiceMock(Topology.class);
+    final java.util.List<org.apache.knox.gateway.topology.Service> services = new java.util.ArrayList<>();
+    for (String role : roles) {
+      final org.apache.knox.gateway.topology.Service svc =
+          EasyMock.createNiceMock(org.apache.knox.gateway.topology.Service.class);
+      EasyMock.expect(svc.getRole()).andReturn(role).anyTimes();
+      EasyMock.replay(svc);
+      services.add(svc);
+    }
+    EasyMock.expect(topology.getServices()).andReturn(services).anyTimes();
+    EasyMock.replay(topology);
+    return topology;
+  }
+
+  private static String[] withRoles(String... roles) {
+    return roles;
+  }
+
+  private static AliasService derbyAlias() throws Exception {
+    final AliasService alias = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.expect(alias.getPasswordFromAliasForGateway(
+        AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
+    EasyMock.expect(alias.getPasswordFromAliasForGateway(
+        AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
+    EasyMock.replay(alias);
+    return alias;
+  }
+
+  private static GatewayConfig derbyConfig() {
+    final GatewayConfigImpl config = new GatewayConfigImpl();
+    config.set(GatewayConfigImpl.GATEWAY_DATABASE_TYPE, DatabaseType.DERBY.type());
+    config.set(GatewayConfigImpl.GATEWAY_DATABASE_NAME, "memory:" + DB_NAME);
+    return config;
+  }
+
+  /** Config mock that returns empty string for getServiceParameter (required for impl detection). */
+  private static GatewayConfig emptyConfig() {
+    final GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(config.getServiceParameter(EasyMock.anyString(), EasyMock.anyString()))
+        .andReturn("").anyTimes();
+    EasyMock.replay(config);
+    return config;
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
index e8cb56015..8645017cc 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
@@ -53,6 +53,8 @@ public class TrustedOidcIssuersSchemaTest {
 
   @BeforeClass
   public static void setUp() throws SQLException {
+    // Derby 10.14 does not recognize locales like en_001; force a standard locale.
+    java.util.Locale.setDefault(java.util.Locale.US);
     derbyConn = DriverManager.getConnection(DERBY_URL);
     hsqlConn = DriverManager.getConnection(HSQL_URL, HSQL_USER, HSQL_PASSWORD);
   }
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 5573b90e2..99ba8b5e3 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
@@ -57,4 +57,17 @@ public interface KnoxIDFConstants {
     String FEDERATED_OP_CONFIG_NAMES = FEDERATED_OP_CONFIG_PREFIX + "names";
 
     String TOKEN_EXCHANGE_TOPOLOGY_NAME = "token.exchange.topology.name";
+
+    // TrustedOidcIssuerService gateway-level params (read from GatewayConfig / gateway-site.xml)
+    String TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS =
+        "gateway.trustedoidcissuer.discovery.cache.ttl.secs";
+    String TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS =
+        "gateway.trustedoidcissuer.discovery.connect.timeout.ms";
+    String TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS =
+        "gateway.trustedoidcissuer.discovery.read.timeout.ms";
+
+    // Default values for gateway-level TrustedOidcIssuerService params
+    int TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS = 600;
+    int TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS = 3000;
+    int TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS = 10000;
 }
diff --git a/pom.xml b/pom.xml
index adf8ff7f0..acc2cd870 100644
--- a/pom.xml
+++ b/pom.xml
@@ -259,6 +259,7 @@
         <mina.version>2.2.8</mina.version>
         <netty.version>4.1.135.Final</netty.version>
         <nimbus-jose-jwt.version>10.9.1</nimbus-jose-jwt.version>
+        <oauth2-oidc-sdk.version>11.37.2</oauth2-oidc-sdk.version>
         <nodejs.version>v22.20.0</nodejs.version>
         <okhttp.version>4.12.0</okhttp.version>
         <opensaml.version>5.2.2</opensaml.version>
@@ -1517,6 +1518,11 @@
                 <artifactId>nimbus-jose-jwt</artifactId>
                 <version>${nimbus-jose-jwt.version}</version>
             </dependency>
+            <dependency>
+                <groupId>com.nimbusds</groupId>
+                <artifactId>oauth2-oidc-sdk</artifactId>
+                <version>${oauth2-oidc-sdk.version}</version>
+            </dependency>
 
             <dependency>
                 <groupId>net.minidev</groupId>


(knox) 16/37: KNOX-3414: harden secrets-at-rest, auth-code single-use, and persistence (1.5, 2.4, 2.11)

Posted by sm...@apache.org.
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 dcd1e01779b95bfc3d39214d820ba35b8d950b44
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Mon Aug 10 21:10:11 2026 +0200

    KNOX-3414: harden secrets-at-rest, auth-code single-use, and persistence (1.5, 2.4, 2.11)
    
    Three KnoxIDF security/correctness hardening findings from the OIDC-provider
    review, landed together.
    
    1.5 - Secrets at rest
    - Federated OP client_secret is now resolvable via AliasService. New optional
      topology param federated.op.<name>.clientSecret.alias
      (FederatedOpConfiguration). AuthorizeResource.resolveClientSecret prefers the
      alias (getPasswordFromAliasForCluster, cluster from GATEWAY_CLUSTER_ATTRIBUTE
      with NO_CLUSTER_NAME fallback); fails closed if a configured alias is
      unresolvable rather than leaking the plaintext param. Plaintext clientSecret
      retained only as a fallback when no alias is set.
    - Federated OP access token is no longer persisted at rest: decorateAuthCodeToken
      stores only the FEDERATED_IDENTITY_ID pointer. Removed the now-dead
      split/join/chunk token helpers and FEDERATED_*_TOKEN_PREFIX constants.
    
    2.4 - Single-use authorization codes (close the replay window)
    - SPI TokenStateService: new default consumeToken(String) - atomic single-use
      consume; exactly one concurrent caller receives true, an absent token is false
      (never throws).
    - DefaultTokenStateService: overrides consumeToken with a true atomic claim
      (tokenExpirations.remove(id) != null), evicting the remaining per-token state.
    - JDBCTokenStateService: overrides consumeToken using the primary-key DELETE as
      the atomic arbiter, evicts the in-memory cache, and fails closed on SQLException
      (the inherited removeToken swallows it and would falsely report a win). Derby
      inherits this.
    - idf TokenResource: validateAuthCode now returns the metadata it already reads;
      handleAuthorizationCodeFlow validates, then atomically consumes the code BEFORE
      issuing any token, stashing the metadata in a per-request attribute consumed by
      the issuance steps (getAuthCodeMetadata). The revoke-in-finally is removed. A
      code that fails validation is deliberately NOT consumed, so replaying with bad
      params cannot burn a victim's still-valid code.
    
    2.11 - Persistence hardening
    - Consent key reshaped to fit KNOX_TOKEN_METADATA.md_name VARCHAR(32):
      AuthorizeResource.consentMetadataKey(subject) = "consent_" +
      first-20-hex(SHA-256(subject)) (28 chars), used by both hasConsent and
      markConsentAccepted so read and write agree. No schema change to md_name.
    - Derby DDL parity: added the missing NOT NULL constraints to the federated
      identity and attribute tables (the UNIQUE index was already present).
    - TOCTOU: JdbcFederatedIdentityService.addFederatedIdentity now inserts and
      catches instead of check-then-insert; a unique-constraint violation
      (SQLIntegrityConstraintViolationException or SQLState class 23, walked up the
      cause chain) is treated as a benign already-exists. The unique index is the
      arbiter.
    - Transaction boundary: FederatedIdentityDatabase.addFederatedIdentity writes the
      core row and attribute rows on a single connection with autocommit off, then
      commits (rollback on failure) so an identity is never persisted without its
      attributes.
    - Double-checked locking: added the missing inner recheck in
      JdbcFederatedIdentityService.init; corrected its misleading exception message.
    - SELECT * replaced with the explicit column list in the by-provider/issuer/subject
      query; KnoxDatabase resolves DDL via getClass().getClassLoader() so each subclass
      loads its own create*.sql.
    
    Tests
    - DefaultTokenStateServiceTest: +3 consumeToken tests (single-use, state removed,
      unknown id).
    - New TokenResourceAuthCodeReplayTest: a losing (already-consumed) redemption
      yields invalid_grant with no issuance; a winning redemption issues exactly once.
    - New ConsentMetadataKeyTest: key fits VARCHAR(32) for realistic subjects,
      deterministic, distinct subjects -> distinct keys.
    - FederatedOpConfigurationTest: +2 (clientSecret alias read / absent by default).
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../apache/knox/gateway/database/KnoxDatabase.java |   7 +-
 .../federation/FederatedIdentityDatabase.java      |  56 +++++---
 .../FederatedIdentityServiceMessages.java          |   3 +
 .../federation/JdbcFederatedIdentityService.java   |  54 ++++++--
 .../token/impl/DefaultTokenStateService.java       |  16 +++
 .../services/token/impl/JDBCTokenStateService.java |  19 +++
 ...noxIDFFederatedIdentityAttributesTableDerby.sql |   4 +-
 .../createKnoxIDFFederatedIdentityTableDerby.sql   |  10 +-
 .../token/impl/DefaultTokenStateServiceTest.java   |  36 +++++
 .../gateway/service/knoxidf/AuthorizeResource.java |  69 +++++++++-
 .../gateway/service/knoxidf/TokenResource.java     |  62 +++++++--
 .../service/knoxidf/ConsentMetadataKeyTest.java    |  65 +++++++++
 .../knoxidf/TokenResourceAuthCodeReplayTest.java   | 153 +++++++++++++++++++++
 .../services/security/token/TokenStateService.java |  26 ++++
 .../util/knoxidf/FederatedOpConfiguration.java     |  14 ++
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |   2 -
 .../knox/gateway/util/knoxidf/KnoxIDFUtils.java    |  28 ----
 .../util/knoxidf/FederatedOpConfigurationTest.java |  25 ++++
 18 files changed, 558 insertions(+), 91 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java
index 261b36899..dd6ee68e8 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java
@@ -16,8 +16,6 @@
  */
 package org.apache.knox.gateway.database;
 
-import org.apache.knox.gateway.services.token.impl.TokenStateDatabase;
-
 import javax.sql.DataSource;
 
 public class KnoxDatabase {
@@ -30,7 +28,10 @@ public class KnoxDatabase {
 
     protected void createTableIfNotExists(String tableName, String createSqlFileName) throws Exception {
         if (!JDBCUtils.tableExists(tableName, dataSource)) {
-            JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, TokenStateDatabase.class.getClassLoader());
+            // Resolve the DDL resource via the actual subclass's classloader so each KnoxDatabase
+            // subclass (TokenStateDatabase, FederatedIdentityDatabase) loads its own create*.sql
+            // rather than being coupled to one hardcoded sibling class's classloader.
+            JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, getClass().getClassLoader());
         }
     }
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java
index 649bf3038..0fc2790a4 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java
@@ -35,8 +35,8 @@ class FederatedIdentityDatabase extends KnoxDatabase {
             + " (id, user_id, provider, external_subject, external_issuer, created_at) VALUES (?, ?, ?, ?, ?, ?)";
     private static final String ADD_FEDERATED_IDENTITY_ATTR_SQL = "INSERT INTO " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME +
             " (identity_id, attr_key, attr_value) VALUES (?, ?, ?)";
-    private static final String FETCH_FEDERATED_IDENTITY_BY_PROV_ISS_SUB_SQL = "SELECT * FROM " + FEDERATED_IDENTITY_TABLE_NAME +
-            " WHERE provider = ? AND external_issuer = ? AND external_subject = ?";
+    private static final String FETCH_FEDERATED_IDENTITY_BY_PROV_ISS_SUB_SQL = "SELECT id, user_id, provider, external_subject, external_issuer, created_at FROM "
+            + FEDERATED_IDENTITY_TABLE_NAME + " WHERE provider = ? AND external_issuer = ? AND external_subject = ?";
     private static final String FETCH_FEDERATED_IDENTITY_SQL_BY_ID = "SELECT id, user_id, provider, external_subject, external_issuer, created_at FROM "
             + FEDERATED_IDENTITY_TABLE_NAME + " WHERE id = ?";
     private static final String FETCH_FEDERATED_IDENTITY_ATTR_SQL = "SELECT attr_key, attr_value FROM " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME + " WHERE identity_id = ?";
@@ -49,26 +49,42 @@ class FederatedIdentityDatabase extends KnoxDatabase {
     }
 
     void addFederatedIdentity(FederatedIdentity identity) throws SQLException {
-        // save core metadata first
-        try (Connection connection = dataSource.getConnection(); PreparedStatement addFederatedIdentityStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_SQL)) {
-            addFederatedIdentityStatement.setString(1, identity.getId());
-            addFederatedIdentityStatement.setString(2, identity.getUserId());
-            addFederatedIdentityStatement.setString(3, identity.getProvider());
-            addFederatedIdentityStatement.setString(4, identity.getExternalSubject());
-            addFederatedIdentityStatement.setString(5, identity.getExternalIssuer());
-            addFederatedIdentityStatement.setTimestamp(6, Timestamp.from(identity.getCreatedAt()));
-            addFederatedIdentityStatement.executeUpdate();
-        }
+        // Persist the core identity row and its attribute rows atomically on a single connection
+        // with autocommit off: either the identity and all its attributes commit together, or the
+        // whole write rolls back. Previously each INSERT ran on its own auto-committed connection,
+        // so a failure between them could leave an identity persisted without its attributes.
+        try (Connection connection = dataSource.getConnection()) {
+            final boolean previousAutoCommit = connection.getAutoCommit();
+            connection.setAutoCommit(false);
+            try {
+                // save core metadata first
+                try (PreparedStatement addFederatedIdentityStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_SQL)) {
+                    addFederatedIdentityStatement.setString(1, identity.getId());
+                    addFederatedIdentityStatement.setString(2, identity.getUserId());
+                    addFederatedIdentityStatement.setString(3, identity.getProvider());
+                    addFederatedIdentityStatement.setString(4, identity.getExternalSubject());
+                    addFederatedIdentityStatement.setString(5, identity.getExternalIssuer());
+                    addFederatedIdentityStatement.setTimestamp(6, Timestamp.from(identity.getCreatedAt()));
+                    addFederatedIdentityStatement.executeUpdate();
+                }
 
-        // save attributes
-        try (Connection connection = dataSource.getConnection(); PreparedStatement addFederatedIdentityAttrStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_ATTR_SQL)) {
-            for (var attribute : identity.getAttributes().entrySet()) {
-                addFederatedIdentityAttrStatement.setString(1, identity.getId());
-                addFederatedIdentityAttrStatement.setString(2, attribute.getKey());
-                addFederatedIdentityAttrStatement.setString(3, attribute.getValue());
-                addFederatedIdentityAttrStatement.addBatch();
+                // save attributes
+                try (PreparedStatement addFederatedIdentityAttrStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_ATTR_SQL)) {
+                    for (var attribute : identity.getAttributes().entrySet()) {
+                        addFederatedIdentityAttrStatement.setString(1, identity.getId());
+                        addFederatedIdentityAttrStatement.setString(2, attribute.getKey());
+                        addFederatedIdentityAttrStatement.setString(3, attribute.getValue());
+                        addFederatedIdentityAttrStatement.addBatch();
+                    }
+                    addFederatedIdentityAttrStatement.executeBatch();
+                }
+                connection.commit();
+            } catch (SQLException e) {
+                connection.rollback();
+                throw e;
+            } finally {
+                connection.setAutoCommit(previousAutoCommit);
             }
-            addFederatedIdentityAttrStatement.executeBatch();
         }
     }
 
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java
index d6f820a1d..1241e54e7 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java
@@ -32,4 +32,7 @@ public interface FederatedIdentityServiceMessages {
 
     @Message(level = MessageLevel.ERROR, text = "An error occurred while fetching federated identity ({0}) from the database : {1}")
     void errorFetchingFederatedIdentityFromDatabase(String id, String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+    @Message(level = MessageLevel.DEBUG, text = "Federated identity ({0} / {1} / {2}) already exists; skipping insert")
+    void federatedIdentityAlreadyExists(String provider, String issuer, String subject);
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java
index 8e26bdc1d..546536e78 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java
@@ -23,6 +23,7 @@ import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.AliasService;
 
 import java.sql.SQLException;
+import java.sql.SQLIntegrityConstraintViolationException;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -42,14 +43,18 @@ public class JdbcFederatedIdentityService implements FederatedIdentityService {
         if (!initialized.get()) {
             initLock.lock();
             try {
-                if (aliasService == null) {
-                    throw new ServiceLifecycleException("The required AliasService reference has not been set.");
-                }
-                try {
-                    this.federatedIdentityDatabase = new FederatedIdentityDatabase(DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
-                    initialized.set(true);
-                } catch (Exception e) {
-                    throw new ServiceLifecycleException("Error while initiating JDBCTokenStateService: " + e, e);
+                // Double-checked locking: re-test under the lock so a thread that blocked while
+                // another was initialising does not re-initialise the database a second time.
+                if (!initialized.get()) {
+                    if (aliasService == null) {
+                        throw new ServiceLifecycleException("The required AliasService reference has not been set.");
+                    }
+                    try {
+                        this.federatedIdentityDatabase = new FederatedIdentityDatabase(DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
+                        initialized.set(true);
+                    } catch (Exception e) {
+                        throw new ServiceLifecycleException("Error while initiating JdbcFederatedIdentityService: " + e, e);
+                    }
                 }
             } finally {
                 initLock.unlock();
@@ -75,16 +80,43 @@ public class JdbcFederatedIdentityService implements FederatedIdentityService {
 
     @Override
     public void addFederatedIdentity(FederatedIdentity identity) {
+        // Insert-and-catch rather than check-then-insert: the UNIQUE(provider, external_issuer,
+        // external_subject) index is the atomic arbiter, so two concurrent requests for the same
+        // external identity cannot both insert. A unique-constraint violation means the row already
+        // exists, which is exactly the desired end state, so it is treated as benign rather than
+        // surfaced as an error (closing the prior TOCTOU race between the pre-check and the insert).
         try {
-            if (findByProviderAndSubject(identity.getProvider(), identity.getExternalIssuer(), identity.getExternalSubject()).isEmpty()) {
-                federatedIdentityDatabase.addFederatedIdentity(identity);
-            }
+            federatedIdentityDatabase.addFederatedIdentity(identity);
         } catch (SQLException e) {
+            if (isUniqueConstraintViolation(e)) {
+                LOG.federatedIdentityAlreadyExists(identity.getProvider(), identity.getExternalIssuer(), identity.getExternalSubject());
+                return;
+            }
             LOG.errorSavingFederatedIdentityInDatabase(identity.getId(), e.getMessage(), e);
             throw new FederatedIdentityServiceException("An error occurred while saving Federated Identity " + identity.getId() + " in the database", e);
         }
     }
 
+    /**
+     * Recognises a unique/primary-key constraint violation across dialects: either a
+     * {@link SQLIntegrityConstraintViolationException} or any {@link SQLException} in the cause
+     * chain whose SQLState is in the {@code 23} (integrity constraint violation) class.
+     */
+    private static boolean isUniqueConstraintViolation(SQLException e) {
+        for (Throwable t = e; t != null; t = t.getCause()) {
+            if (t instanceof SQLIntegrityConstraintViolationException) {
+                return true;
+            }
+            if (t instanceof SQLException) {
+                final String sqlState = ((SQLException) t).getSQLState();
+                if (sqlState != null && sqlState.startsWith("23")) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
     @Override
     public Optional<FederatedIdentity> findByProviderAndSubject(String provider, String issuer, String subject) {
         try {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
index afca435bd..95d0b8eab 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
@@ -265,6 +265,22 @@ public class DefaultTokenStateService implements TokenStateService {
     log.revokedToken(Tokens.getTokenIDDisplayText(tokenId));
   }
 
+  @Override
+  public boolean consumeToken(final String tokenId) {
+    validateTokenIdentifier(tokenId);
+    // Atomic single-use claim: ConcurrentHashMap#remove returns the prior value to exactly one
+    // caller, so concurrent redemptions of the same token can never both observe it as present.
+    final boolean claimed = tokenExpirations.remove(tokenId) != null;
+    if (claimed) {
+      // Evict the remaining per-token state (idempotent for a token we won the race for).
+      tokenIssueTimes.remove(tokenId);
+      maxTokenLifetimes.remove(tokenId);
+      metadataMap.remove(tokenId);
+      log.revokedToken(Tokens.getTokenIDDisplayText(tokenId));
+    }
+    return claimed;
+  }
+
   @Override
   public boolean isExpired(final JWTToken token) throws UnknownTokenException {
     return getTokenExpiration(token) <= System.currentTimeMillis();
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java
index 4dfc46d29..5d60fbe33 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java
@@ -240,6 +240,25 @@ public class JDBCTokenStateService extends AbstractPersistentTokenStateService i
     }
   }
 
+  @Override
+  public boolean consumeToken(String tokenId) {
+    // The single-row primary-key DELETE is the atomic arbiter: only the caller whose statement
+    // actually removed the row observes rowsAffected == 1, so exactly one concurrent redemption
+    // wins. Fail closed on a SQL error (report "not consumed by us") rather than the inherited
+    // removeToken() behaviour of swallowing the exception, which would falsely signal a win.
+    try {
+      final boolean removed = tokenDatabase.removeToken(tokenId);
+      if (removed) {
+        super.removeTokens(Collections.singleton(tokenId)); // evict the in-memory cache copy
+        log.removedTokenFromDatabase(Tokens.getTokenIDDisplayText(tokenId));
+      }
+      return removed;
+    } catch (SQLException e) {
+      log.errorRemovingTokenFromDatabase(Tokens.getTokenIDDisplayText(tokenId), e.getMessage(), e);
+      return false;
+    }
+  }
+
   @Override
   protected void evictExpiredTokens() {
     try {
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
index 13ae9ab11..90c70ff0f 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
@@ -14,8 +14,8 @@
 --  the License.
 
 CREATE TABLE FEDERATED_IDENTITY_ATTR (
-    identity_id VARCHAR(36),
-    attr_key    VARCHAR(128),
+    identity_id VARCHAR(36)  NOT NULL,
+    attr_key    VARCHAR(128) NOT NULL,
     attr_value  CLOB,
     PRIMARY KEY (identity_id, attr_key),
     CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
index 7152d4c71..acaf1c340 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
@@ -15,11 +15,11 @@
 
 CREATE TABLE FEDERATED_IDENTITY (
     id               VARCHAR(36) PRIMARY KEY,
-    user_id          VARCHAR(36),
-    provider         VARCHAR(64),
-    external_subject VARCHAR(255),
-    external_issuer  VARCHAR(255),
-    created_at       TIMESTAMP
+    user_id          VARCHAR(36)  NOT NULL,
+    provider         VARCHAR(64)  NOT NULL,
+    external_subject VARCHAR(255) NOT NULL,
+    external_issuer  VARCHAR(255) NOT NULL,
+    created_at       TIMESTAMP    NOT NULL
 );
 
 CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
index 419d077c6..3cb0b2738 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
@@ -162,6 +162,42 @@ public class DefaultTokenStateServiceTest {
     tss.isExpired(token);
   }
 
+  @Test
+  public void testConsumeToken_SingleUse() throws Exception {
+    // Single-use semantics for auth codes: the first consume wins, a second consume of the same
+    // id loses (the token is already gone), and the token state is actually removed.
+    final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
+    final TokenStateService tss = createTokenStateService();
+    final String tokenId = TokenUtils.getTokenId(token);
+
+    addToken(tss, token, System.currentTimeMillis());
+
+    assertTrue("First consume should win.", tss.consumeToken(tokenId));
+    assertFalse("Second consume of the same token must lose.", tss.consumeToken(tokenId));
+  }
+
+  @Test(expected = UnknownTokenException.class)
+  public void testConsumeToken_RemovesState() throws Exception {
+    final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
+    final TokenStateService tss = createTokenStateService();
+    final String tokenId = TokenUtils.getTokenId(token);
+
+    addToken(tss, token, System.currentTimeMillis());
+    assertTrue(tss.consumeToken(tokenId));
+
+    // The token must no longer be known after being consumed.
+    tss.getTokenExpiration(tokenId);
+  }
+
+  @Test
+  public void testConsumeToken_UnknownToken() throws Exception {
+    // An id that was never stored cannot be "won" - consume reports false rather than throwing.
+    final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
+    final TokenStateService tss = createTokenStateService();
+
+    assertFalse(tss.consumeToken(TokenUtils.getTokenId(token)));
+  }
+
   @Test
   public void testRenewal() throws Exception {
     final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index 4fd703c7a..d778aa7e1 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -37,6 +37,7 @@ import org.apache.knox.gateway.services.ServiceType;
 import org.apache.http.ssl.SSLContexts;
 import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity;
 import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
+import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.AliasServiceException;
 import org.apache.knox.gateway.services.security.KeystoreService;
 import org.apache.knox.gateway.services.security.token.JWTokenAuthority;
@@ -51,7 +52,6 @@ import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadata;
 import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadataStore;
 import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration;
 import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore;
-import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils;
 
 import javax.annotation.PostConstruct;
 import javax.servlet.ServletContext;
@@ -66,6 +66,8 @@ import javax.net.ssl.SSLContext;
 import java.io.UnsupportedEncodingException;
 import java.net.URI;
 import java.security.KeyStore;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.net.URISyntaxException;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
@@ -189,7 +191,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
     private boolean hasConsent(final AuthorizeRequestMetadata authorizeRequestMetadata) {
         try {
             final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(authorizeRequestMetadata.getClientId());
-            final String consentKey = "consentAccepted_" + authorizeRequestMetadata.getSubject();
+            final String consentKey = consentMetadataKey(authorizeRequestMetadata.getSubject());
             final String storedScopes = tokenMetadata.getMetadataMap().get(consentKey);
             if (storedScopes == null || storedScopes.isEmpty()) {
                 return false;
@@ -204,10 +206,36 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
 
     private void markConsentAccepted(AuthorizeRequestMetadata authorizeRequestMetadata) {
         final TokenMetadata consentAcceptedMetadata = new TokenMetadata();
-        consentAcceptedMetadata.add("consentAccepted_" + authorizeRequestMetadata.getSubject(), authorizeRequestMetadata.getJoinedRequestedScopes());
+        consentAcceptedMetadata.add(consentMetadataKey(authorizeRequestMetadata.getSubject()), authorizeRequestMetadata.getJoinedRequestedScopes());
         tokenStateService.addMetadata(authorizeRequestMetadata.getClientId(), consentAcceptedMetadata);
     }
 
+    /**
+     * Derives the metadata key under which a subject's granted consent scopes are stored. Consent is
+     * persisted in {@code KNOX_TOKEN_METADATA.md_name}, which is {@code VARCHAR(32)}; the previous
+     * {@code "consentAccepted_" + subject} key overflowed that for realistic subjects (federated
+     * UUID subjects, long usernames), silently truncating or failing the write on strict dialects.
+     * This derives a fixed-width key {@code "consent_" + first-20-hex-chars(SHA-256(subject))} = 28
+     * chars, comfortably within the column. ~80 bits of hash is collision-safe for any realistic
+     * user population, and the derivation is uniform for plain usernames and UUID subjects alike.
+     * {@link #hasConsent} and {@link #markConsentAccepted} both route through here so read and write
+     * always agree on the key.
+     */
+    static String consentMetadataKey(final String subject) {
+        try {
+            final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            final byte[] hash = digest.digest((subject == null ? "" : subject).getBytes(StandardCharsets.UTF_8));
+            final StringBuilder hex = new StringBuilder("consent_");
+            for (int i = 0; i < 10; i++) { // 10 bytes -> 20 hex chars
+                hex.append(String.format(Locale.US, "%02x", hash[i]));
+            }
+            return hex.toString();
+        } catch (NoSuchAlgorithmException e) {
+            // SHA-256 is a required algorithm on every JRE; its absence is unrecoverable.
+            throw new IllegalStateException("SHA-256 is required but unavailable", e);
+        }
+    }
+
     private Response getAuthCodeFromKnox(final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair<String, String> federatedTokens) {
         final Response tokenResponse = getAuthenticationToken();
         if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) {
@@ -307,8 +335,11 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
             authCodeTokenMap.put(CODE_CHALLENGE_METHOD, authorizeRequestMetadata.getCodeChallengeMethod());
         }
         if (federatedTokens != null) {
+            // Persist only the pointer to the (separately stored) federated identity. The OP's
+            // access token (federatedTokens.getRight()) is deliberately NOT persisted: nothing reads
+            // it back, and storing an OP bearer secret in plaintext token metadata is a secret-at-rest
+            // exposure. If a future feature needs it, store it encrypted, not in the clear.
             authCodeTokenMap.put(FEDERATED_IDENTITY_ID, federatedTokens.getLeft());
-            authCodeTokenMap.putAll(KnoxIDFUtils.splitFederatedToken(federatedTokens.getRight(), false));
         }
         tokenStateService.addMetadata(tokenId, new TokenMetadata(authCodeTokenMap));
     }
@@ -413,13 +444,41 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         }
     }
 
+    /**
+     * Resolves the federated OP's client secret for the back-channel token request. An
+     * {@code AliasService} credential alias ({@code federated.op.<name>.clientSecret.alias}) is the
+     * preferred, secure source and takes precedence: when it resolves to a value, that value is
+     * used and the plaintext {@code clientSecret} topology param is never consulted. The plaintext
+     * param remains supported as a fallback only when no alias is configured, so existing
+     * deployments keep working. If an alias is configured but cannot be resolved we fail closed
+     * (return {@code null}) rather than silently leaking through to the plaintext param, so a
+     * misconfigured alias surfaces as an auth failure instead of masking the intended secure source.
+     */
+    private String resolveClientSecret(final FederatedOpConfiguration opConfig) {
+        final String alias = opConfig.getClientSecretAlias();
+        if (StringUtils.isBlank(alias)) {
+            return opConfig.getClientSecret();
+        }
+        try {
+            final AliasService aliasService = getGatewayServices().getService(ServiceType.ALIAS_SERVICE);
+            String clusterName = (String) servletContext.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE);
+            if (StringUtils.isBlank(clusterName)) {
+                clusterName = AliasService.NO_CLUSTER_NAME;
+            }
+            final char[] secret = aliasService.getPasswordFromAliasForCluster(clusterName, alias, false);
+            return secret == null ? null : new String(secret);
+        } catch (AliasServiceException e) {
+            return null;
+        }
+    }
+
     private Response fetchFederatedTokens(final String code, FederatedOpConfiguration opConfig) {
         final List<NameValuePair> params = new ArrayList<>();
         params.add(new BasicNameValuePair(CODE, code));
         params.add(new BasicNameValuePair(REDIRECT_URI, opConfig.getAuthorizeCallback()));
         params.add(new BasicNameValuePair(GRANT_TYPE, "authorization_code"));
         params.add(new BasicNameValuePair(CLIENT_ID, opConfig.getClientId()));
-        params.add(new BasicNameValuePair(CLIENT_SECRET, opConfig.getClientSecret()));
+        params.add(new BasicNameValuePair(CLIENT_SECRET, resolveClientSecret(opConfig)));
 
         try (CloseableHttpClient httpClient = createFederatedHttpClient()) {
             HttpPost post = new HttpPost(opConfig.getTokenEndpoint());
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 97e24378c..540df06f6 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
@@ -81,10 +81,18 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
 @Produces(MediaType.APPLICATION_JSON)
 public class TokenResource extends PasscodeTokenResourceBase {
     static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/token";
+
+    // Per-request stash for the auth-code TokenMetadata read during validation. The code is
+    // atomically consumed (deleted) BEFORE token issuance to close the replay window, so the
+    // issuance steps (buildUserContext/addArbitraryTokenMetadata/buildResponseMap) can no longer
+    // re-read it from the store; they read this request attribute instead. This resource is a
+    // singleton, but the @Context request is a per-request proxy, so the attribute is request-scoped.
+    private static final String AUTH_CODE_METADATA_ATTR = "knoxidf.authCode.metadata";
+
     private UserParamsProvider userParamsProvider;
 
     @Context
-    private HttpServletRequest request;
+    HttpServletRequest request; // package-private for test injection; @Context injection is reflective
 
     @Context
     private ServletContext servletContext;
@@ -140,8 +148,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
     @Override
     protected UserContext buildUserContext(HttpServletRequest request) {
         try {
-            final String code = getRequestParam(CODE);
-            final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(code);
+            final TokenMetadata tokenMetadata = getAuthCodeMetadata();
             final String scope = tokenMetadata.getMetadata(SCOPE);
             final Map<String, Object> userParams = userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope);
             userParams.put(SCOPE, scope);
@@ -158,7 +165,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
             super.addArbitraryTokenMetadata(tokenMetadata);
             final String code = getRequestParam(CODE);
             if (StringUtils.isNotBlank(code)) {
-                final TokenMetadata authCodeTokenMetadata = tokenStateService.getTokenMetadata(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)
@@ -181,7 +188,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
         TokenMetadata authCodeTokenMetadata = null;
         if (StringUtils.isNotBlank(code)) {
             try {
-                authCodeTokenMetadata = tokenStateService.getTokenMetadata(code);
+                authCodeTokenMetadata = getAuthCodeMetadata();
             } catch (UnknownTokenException e) {
                 //NOP
             }
@@ -251,25 +258,49 @@ public class TokenResource extends PasscodeTokenResourceBase {
         }
     }
 
-    private Response handleAuthorizationCodeFlow() {
+    // Package-private for testability (single-use replay guard is exercised by
+    // TokenResourceAuthCodeReplayTest); not part of the public resource API.
+    Response handleAuthorizationCodeFlow() {
         final String code = getRequestParam(CODE);
         final String redirectUri = getRequestParam(REDIRECT_URI);
 
+        final TokenMetadata authCodeMetadata;
         try {
-            validateAuthCode(code, redirectUri);
-            return getAuthenticationToken();
+            authCodeMetadata = validateAuthCode(code, redirectUri);
         } catch (AuthTokenValidationError e) {
             return error("Auth code validation error", e.getMessage());
-        } finally {
-            try {
-                tokenStateService.revokeToken(code);
-            } catch (UnknownTokenException e) {
-                //NOP: this should have been handled by the above UnknownTokenException already
-            }
         }
+
+        // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent
+        // redemptions of the same code, exactly one wins the consume and proceeds; the losers get
+        // invalid_grant. This closes the replay window that existed when the code was only revoked
+        // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT
+        // consumed here, so replaying with bad params cannot burn a victim's still-valid code.
+        if (!tokenStateService.consumeToken(code)) {
+            return error("invalid_grant", "Authorization code has already been redeemed");
+        }
+
+        // The code is now gone from the store; hand the already-validated metadata to the issuance
+        // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code.
+        request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata);
+        return getAuthenticationToken();
+    }
+
+    /**
+     * Returns the auth-code {@link TokenMetadata} captured at validation time and stashed in a
+     * request attribute by {@link #handleAuthorizationCodeFlow()}. Because the code is consumed
+     * (deleted) before token issuance, the issuance steps can no longer re-read it from the store;
+     * this serves the cached copy, falling back to a store read only if the attribute is absent.
+     */
+    private TokenMetadata getAuthCodeMetadata() throws UnknownTokenException {
+        final Object cached = request.getAttribute(AUTH_CODE_METADATA_ATTR);
+        if (cached instanceof TokenMetadata) {
+            return (TokenMetadata) cached;
+        }
+        return tokenStateService.getTokenMetadata(getRequestParam(CODE));
     }
 
-    private void validateAuthCode(String code, String redirectUri) throws AuthTokenValidationError {
+    private TokenMetadata validateAuthCode(String code, String redirectUri) throws AuthTokenValidationError {
         try {
             if (code == null || code.isEmpty()) {
                 throw new AuthTokenValidationError("Invalid request: missing code");
@@ -316,6 +347,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
             } else if (!isValidClientSecret(clientId, getRequestParam(CLIENT_SECRET))) {
                 throw new AuthTokenValidationError("Invalid client authentication");
             }
+            return authCodeTokenMetadata;
         } catch (UnknownTokenException e) {
             throw new AuthTokenValidationError("Unknown auth_code");
         }
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java
new file mode 100644
index 000000000..e4feee4d6
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Verifies the consent metadata key derivation (finding 2.11). Consent is stored in
+ * {@code KNOX_TOKEN_METADATA.md_name VARCHAR(32)}; the key must therefore stay within 32 chars for
+ * every subject, be deterministic (so a later read finds an earlier write), and separate distinct
+ * subjects.
+ */
+public class ConsentMetadataKeyTest {
+
+  /** The backing column is VARCHAR(32). */
+  private static final int MD_NAME_MAX = 32;
+
+  @Test
+  public void testKeyFitsColumnForRealisticSubjects() {
+    final String[] subjects = {
+        "alice",
+        "administrator@corp.example.com",
+        // a federated UUID subject - the case that overflowed the old "consentAccepted_" + subject
+        "b9f8e7d6-c5a4-4321-9876-0123456789abcdef-very-long-external-subject-identifier",
+        "",
+    };
+    for (final String subject : subjects) {
+      final String key = AuthorizeResource.consentMetadataKey(subject);
+      assertTrue("Key '" + key + "' (" + key.length() + " chars) must fit VARCHAR(" + MD_NAME_MAX + ")",
+          key.length() <= MD_NAME_MAX);
+      assertTrue("Key should carry the consent_ prefix", key.startsWith("consent_"));
+    }
+  }
+
+  @Test
+  public void testKeyIsDeterministic() {
+    final String subject = "b9f8e7d6-c5a4-4321-9876-0123456789ab";
+    assertEquals("Same subject must always derive the same key (read must match write).",
+        AuthorizeResource.consentMetadataKey(subject), AuthorizeResource.consentMetadataKey(subject));
+  }
+
+  @Test
+  public void testDistinctSubjectsDeriveDistinctKeys() {
+    assertNotEquals(AuthorizeResource.consentMetadataKey("alice"),
+        AuthorizeResource.consentMetadataKey("bob"));
+  }
+}
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java
new file mode 100644
index 000000000..ac0fb6cab
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.impl.TokenMAC;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies the single-use enforcement of the authorization_code grant (finding 2.4). The code must
+ * be atomically consumed BEFORE any token is issued: exactly one of N concurrent redemptions wins
+ * the consume and proceeds to issuance; the losers are rejected with {@code invalid_grant} and no
+ * token is minted. This closes the replay window that existed when the code was only revoked in a
+ * {@code finally} block after issuance.
+ */
+public class TokenResourceAuthCodeReplayTest {
+
+  private static final String AUTH_CODE = "auth-code-xyz";
+  private static final String CLIENT = "client-abc";
+  private static final String REDIRECT = "https://app.example/cb";
+  private static final String USER_NAME = "alice";
+  private static final long ISSUE_TIME = 1_700_000_000_000L;
+  private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
+
+  private TokenStateService tokenStateService;
+  private TestableTokenResource resource;
+  private final AtomicInteger issuedCount = new AtomicInteger();
+
+  /**
+   * Exposes field injection and stubs out the heavy token-issuance path so the replay guard can be
+   * exercised in isolation. {@code getAuthenticationToken} is the step that mints tokens; here it
+   * only records that issuance was reached and returns a sentinel.
+   */
+  final class TestableTokenResource extends TokenResource {
+    void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) {
+      this.tokenStateService = tss;
+      this.tokenMAC = mac;
+      this.request = req;
+    }
+
+    @Override
+    public Response getAuthenticationToken() {
+      issuedCount.incrementAndGet();
+      return Response.ok("issued").build();
+    }
+  }
+
+  private static String wireSecret(final String tokenId, final String rawPasscode) {
+    final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8))
+        + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8));
+    return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray());
+    final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE);
+
+    // Metadata for the authorization code being redeemed (confidential client, no PKCE challenge).
+    final TokenMetadata authCodeMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(authCodeMetadata.isAuthCode()).andReturn(true).anyTimes();
+    EasyMock.expect(authCodeMetadata.getMetadata(REDIRECT_URI)).andReturn(REDIRECT).anyTimes();
+    EasyMock.expect(authCodeMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(authCodeMetadata.getMetadata(CODE_CHALLENGE)).andReturn(null).anyTimes();
+    EasyMock.replay(authCodeMetadata);
+
+    // Metadata for the confidential client, used to authenticate the client_secret.
+    final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes();
+    EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes();
+    EasyMock.replay(clientMetadata);
+
+    tokenStateService = EasyMock.createNiceMock(TokenStateService.class);
+    EasyMock.expect(tokenStateService.getTokenMetadata(AUTH_CODE)).andReturn(authCodeMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenExpiration(AUTH_CODE))
+        .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5)).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes();
+    // consumeToken behaviour is set per-test (win vs. lose) before replay().
+
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getParameter(CODE)).andReturn(AUTH_CODE).anyTimes();
+    EasyMock.expect(req.getParameter(REDIRECT_URI)).andReturn(REDIRECT).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes();
+    EasyMock.replay(req);
+
+    resource = new TestableTokenResource();
+    resource.inject(tokenStateService, tokenMAC, req);
+  }
+
+  @Test
+  public void testFirstRedemptionConsumesThenIssues() {
+    EasyMock.expect(tokenStateService.consumeToken(AUTH_CODE)).andReturn(true).once();
+    EasyMock.replay(tokenStateService);
+
+    final Response response = resource.handleAuthorizationCodeFlow();
+
+    assertEquals("A validated, freshly-consumed code should issue a token.",
+        Response.Status.OK.getStatusCode(), response.getStatus());
+    assertEquals("Exactly one issuance for a single winning redemption.", 1, issuedCount.get());
+    EasyMock.verify(tokenStateService);
+  }
+
+  @Test
+  public void testReplayedCodeIsRejectedWithoutIssuing() {
+    // Simulate a concurrent redemption having already consumed the code: consumeToken loses.
+    EasyMock.expect(tokenStateService.consumeToken(AUTH_CODE)).andReturn(false).once();
+    EasyMock.replay(tokenStateService);
+
+    final Response response = resource.handleAuthorizationCodeFlow();
+
+    assertEquals("A code already consumed by a concurrent redemption must be rejected.",
+        Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+    assertTrue("The error body should identify the invalid_grant condition.",
+        String.valueOf(response.getEntity()).contains("invalid_grant"));
+    assertEquals("A losing redemption must not mint any token.", 0, issuedCount.get());
+    EasyMock.verify(tokenStateService);
+  }
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
index 2d3ea1b20..82d3331ee 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
@@ -102,6 +102,32 @@ public interface TokenStateService extends Service {
    */
   void revokeToken(String tokenId) throws UnknownTokenException;
 
+  /**
+   * Atomically consume (revoke) the specified token, reporting whether <em>this</em> caller
+   * performed the removal. This enforces single-use semantics (e.g. OAuth authorization codes)
+   * under concurrent redemption: of N callers racing to consume the same token, exactly one
+   * receives {@code true} and all others receive {@code false} because the token was already gone.
+   * Unlike {@link #revokeToken(String)}, an absent token is reported as {@code false} rather than
+   * raising {@link UnknownTokenException}.
+   * <p>
+   * The default implementation delegates to {@link #revokeToken(String)} and is only as atomic as
+   * that method; implementations backed by a store that can remove-and-report atomically (a
+   * concurrent-map removal or a primary-key {@code DELETE}) should override this to provide a true
+   * single-winner guarantee.
+   *
+   * @param tokenId The token unique identifier.
+   * @return {@code true} iff this invocation removed a present token; {@code false} if it was
+   *         already absent (never existed, or consumed by a concurrent caller).
+   */
+  default boolean consumeToken(String tokenId) {
+    try {
+      revokeToken(tokenId);
+      return true;
+    } catch (UnknownTokenException e) {
+      return false;
+    }
+  }
+
   /**
    * Extend the lifetime of the specified token by the default amount of time.
    *
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java
index 8ebdea5b1..b82d34441 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java
@@ -23,6 +23,7 @@ public class FederatedOpConfiguration {
     private final String name;
     private final String clientId;
     private final String clientSecret;
+    private final String clientSecretAlias;
     private final String tokenEndpoint;
     private final String authorizeEndpoint;
     private final String userInfoEndpoint;
@@ -41,6 +42,10 @@ public class FederatedOpConfiguration {
         this.enabled = Boolean.parseBoolean(servletContext.getInitParameter(prefix + "enabled"));
         this.clientId = servletContext.getInitParameter(prefix + "clientId");
         this.clientSecret = servletContext.getInitParameter(prefix + "clientSecret");
+        // Preferred, secure source for the OP client secret: an AliasService credential alias.
+        // Resolved at point of use (AuthorizeResource) since this holder has no access to services.
+        // When set it takes precedence over the plaintext clientSecret param above.
+        this.clientSecretAlias = servletContext.getInitParameter(prefix + "clientSecret.alias");
         this.tokenEndpoint = servletContext.getInitParameter(prefix + "token.endpoint");
         this.authorizeEndpoint = servletContext.getInitParameter(prefix + "authorize.endpoint");
         this.authorizeCallback = servletContext.getInitParameter(prefix + "authorize.callback");
@@ -70,6 +75,15 @@ public class FederatedOpConfiguration {
         return clientSecret;
     }
 
+    /**
+     * @return the name of the AliasService credential alias holding this OP's client secret, or
+     * {@code null}/blank when the deployment supplies the secret via the plaintext
+     * {@code clientSecret} param instead. When set, the alias is authoritative.
+     */
+    public String getClientSecretAlias() {
+        return clientSecretAlias;
+    }
+
     String getAuthorizeEndpoint() {
         return authorizeEndpoint;
     }
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 aabb85684..57e8b3caf 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
@@ -52,8 +52,6 @@ public interface KnoxIDFConstants {
     String SCOPE_ATTRIBUTE = "X-Token-Scope";
 
     String FEDERATED_IDENTITY_ID = "federated_identity_id";
-    String FEDERATED_ID_TOKEN_PREFIX = "fed_id_";
-    String FEDERATED_ACCESS_TOKEN_PREFIX = "fed_access_";
     String FEDERATED_OP_CONFIG_PREFIX = "federated.op.";
     String FEDERATED_OP_CONFIG_NAMES = FEDERATED_OP_CONFIG_PREFIX + "names";
 
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
index 989a17e61..98aeb8520 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
@@ -25,42 +25,14 @@ import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.Response;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
-import java.util.stream.Collectors;
 
 
 public class KnoxIDFUtils {
 
-    private static final int CHUNK_SIZE = 255;
-
-    public static Map<String, String> splitFederatedToken(String token, boolean idToken) {
-        final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX;
-        final Map<String, String> parts = new LinkedHashMap<>();
-        int i = 0, part = 1;
-        while (i < token.length()) {
-            int end = Math.min(i + CHUNK_SIZE, token.length());
-            parts.put(prefix + part++, token.substring(i, end));
-            i = end;
-        }
-        return parts;
-    }
-
-    public static String joinFederatedToken(Map<String, String> tokenMetadataMap, boolean idToken) {
-        final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX;
-        return tokenMetadataMap.entrySet().stream()
-                .filter(e -> e.getKey().startsWith(prefix))
-                .sorted(Map.Entry.comparingByKey(
-                        Comparator.comparingInt(k -> Integer.parseInt(k.replace(prefix, "")))
-                ))
-                .map(Map.Entry::getValue)
-                .collect(Collectors.joining());
-    }
-
     public static Response error(String error, String description) {
         final Map<String, String> errorMap = new HashMap<>();
         errorMap.put("error", error);
diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java
index cfe74d8d9..8d1b87e54 100644
--- a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java
+++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java
@@ -59,6 +59,31 @@ public class FederatedOpConfigurationTest {
     assertEquals("RS256", config.getSignatureAlgorithm());
   }
 
+  @Test
+  public void testClientSecretAliasIsRead() {
+    final ServletContext context = EasyMock.createNiceMock(ServletContext.class);
+    EasyMock.expect(context.getInitParameter(PREFIX + "clientSecret")).andReturn("plaintext-secret").anyTimes();
+    EasyMock.expect(context.getInitParameter(PREFIX + "clientSecret.alias")).andReturn("keycloak-op-secret").anyTimes();
+    EasyMock.replay(context);
+
+    final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP);
+
+    // Both are exposed; AuthorizeResource#resolveClientSecret decides precedence (alias wins).
+    assertEquals("plaintext-secret", config.getClientSecret());
+    assertEquals("keycloak-op-secret", config.getClientSecretAlias());
+  }
+
+  @Test
+  public void testClientSecretAliasAbsentByDefault() {
+    final ServletContext context = EasyMock.createNiceMock(ServletContext.class);
+    EasyMock.replay(context);
+
+    final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP);
+
+    // No alias configured -> resolveClientSecret falls back to the plaintext clientSecret param.
+    assertNull(config.getClientSecretAlias());
+  }
+
   @Test
   public void testVerificationParamsAbsentByDefault() {
     final ServletContext context = EasyMock.createNiceMock(ServletContext.class);


(knox) 24/37: KNOX-3414: normalize redirect_uri path before wildcard match (review finding M3)

Posted by sm...@apache.org.
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 ce091a2fb70e134b4c47ece88be73ba42079b9b8
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:24:13 2026 +0200

    KNOX-3414: normalize redirect_uri path before wildcard match (review finding M3)
    
    matchesRedirectUri did a startsWith on the un-normalized requested path in the
    wildcard branch, so a registration of https://app.example/callback/* matched
    https://app.example/callback/../admin (path "/callback/../admin" starts with
    "/callback") and the authorization code was delivered to /admin -- a same-host
    open redirect. The exact, non-wildcard branch was already safe.
    
    Normalize both the base and requested paths (URI.normalize()) before the prefix
    compare so "/callback/../admin" collapses to "/admin" and no longer matches the
    "/callback" prefix. Genuine sub-paths under the wildcard prefix still match.
    
    Covered by AuthorizeResourceRedirectUriMatchTest.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/AuthorizeResource.java | 12 +++-
 .../AuthorizeResourceRedirectUriMatchTest.java     | 74 ++++++++++++++++++++++
 2 files changed, 83 insertions(+), 3 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index 0ba9ac5bd..9747d3c63 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -422,7 +422,9 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         return basicVerificationResponse;
     }
 
-    private boolean matchesRedirectUri(String requestedUri, Set<String> registeredUris) {
+    // Package-private for testability (wildcard path-traversal matching is exercised by
+    // AuthorizeResourceRedirectUriMatchTest); not part of the public resource API.
+    boolean matchesRedirectUri(String requestedUri, Set<String> registeredUris) {
         final URI requested = parseUri(requestedUri);
         if (requested == null) {
             return false;
@@ -434,8 +436,12 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
                 // "https://good.example*" match "https://good.example.evil.com".
                 final URI base = parseUri(registered.substring(0, registered.length() - 1));
                 if (base != null && sameOrigin(base, requested)) {
-                    final String basePath = base.getPath() == null ? "" : base.getPath();
-                    final String reqPath = requested.getPath() == null ? "" : requested.getPath();
+                    // Normalize the requested path before the prefix compare so a traversal segment
+                    // cannot escape the registered prefix: a raw startsWith would let
+                    // ".../callback/../admin" match ".../callback/*" and deliver the code to /admin.
+                    // normalize() collapses "/callback/../admin" to "/admin", which no longer matches.
+                    final String basePath = base.normalize().getPath() == null ? "" : base.normalize().getPath();
+                    final String reqPath = requested.normalize().getPath() == null ? "" : requested.normalize().getPath();
                     if (reqPath.startsWith(basePath)) {
                         return true;
                     }
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java
new file mode 100644
index 000000000..01c88eaf9
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.Test;
+
+/**
+ * Verifies wildcard redirect_uri matching rejects path traversal (review finding M3). A wildcard
+ * registration such as {@code https://app.example/callback/*} must not match
+ * {@code https://app.example/callback/../admin}: a raw startsWith on the un-normalized path would
+ * let the traversal escape the registered prefix and deliver the authorization code to /admin
+ * (a same-host open redirect). The path is normalized before the prefix compare.
+ */
+public class AuthorizeResourceRedirectUriMatchTest {
+
+  private final AuthorizeResource resource = new AuthorizeResource();
+
+  private boolean matches(final String requested, final String registered) {
+    final Set<String> registeredUris = Collections.singleton(registered);
+    return resource.matchesRedirectUri(requested, registeredUris);
+  }
+
+  @Test
+  public void testTraversalEscapingWildcardPrefixIsRejected() {
+    assertFalse("A traversal that resolves outside the registered prefix must be rejected.",
+        matches("https://app.example/callback/../admin", "https://app.example/callback/*"));
+  }
+
+  @Test
+  public void testEncodedPrefixSuffixStillMatches() {
+    assertTrue("A genuine path under the wildcard prefix must still match.",
+        matches("https://app.example/callback/oauth", "https://app.example/callback/*"));
+  }
+
+  @Test
+  public void testExactPrefixMatchesWildcard() {
+    assertTrue("The wildcard base path itself must match.",
+        matches("https://app.example/callback/", "https://app.example/callback/*"));
+  }
+
+  @Test
+  public void testDifferentOriginIsRejected() {
+    assertFalse("A same-prefix path on a different host must be rejected.",
+        matches("https://app.example.evil.com/callback/x", "https://app.example/callback/*"));
+  }
+
+  @Test
+  public void testExact(){
+    assertTrue("An exact non-wildcard registration must match verbatim.",
+        matches("https://app.example/cb", "https://app.example/cb"));
+    assertFalse("An exact registration must not match a different path.",
+        matches("https://app.example/cb2", "https://app.example/cb"));
+  }
+}


(knox) 14/37: KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute on token-exchange (#1342)

Posted by sm...@apache.org.
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 cd163171ab6155440373de7bf7e9d0d1d3b03bde
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Wed Aug 12 02:09:52 2026 -0700

    KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute on token-exchange (#1342)
---
 .../federation/jwt/filter/AbstractJWTFilter.java   | 153 +++--
 .../federation/jwt/filter/JWTFederationFilter.java |  50 +-
 .../jwt/filter/TokenExchangeHandler.java           |  18 +-
 .../JWTFederationFilterTokenExchangeTest.java      | 737 +++++++++++++++++++++
 ...WTFederationFilterTokenExchangeRoutingTest.java |   2 +-
 .../jwt/filter/TokenExchangeHandlerTest.java       |  12 +-
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |   1 +
 7 files changed, 908 insertions(+), 65 deletions(-)

diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
index 502928891..bbf396dd6 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
@@ -469,67 +469,132 @@ public abstract class AbstractJWTFilter implements Filter {
     final String tokenId = TokenUtils.getTokenId(token);
     final String displayableTokenId = Tokens.getTokenIDDisplayText(tokenId);
     final String displayableToken = Tokens.getTokenDisplayText(token.toString());
-    // confirm that issuer matches the intended target
     if (expectedIssuers.contains(token.getIssuer())) {
-      // if there is no expiration data then the lifecycle is tied entirely to
-      // the cookie validity - otherwise ensure that the current time is before
-      // the designated expiration time
-      try {
-        if (tokenIsStillValid(token)) {
-          boolean audValid = validateAudiences(token);
-          if (audValid) {
-            Date nbf = token.getNotBeforeDate();
-            if (nbf == null || new Date().after(nbf)) {
-              final TokenMetadata tokenMetadata = tokenStateService == null ? null : tokenStateService.getTokenMetadata(tokenId);
-              if (isTokenEnabled(tokenMetadata)) {
-                if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
-                  if (verifyTokenSignature(token)) {
-                    markLastUsedAt(tokenId, tokenMetadata);
-                    return true;
-                  } else {
-                    log.failedToVerifyTokenSignature(displayableToken, displayableTokenId);
-                    handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, null);
-                  }
+      // Issuer in the static trusted list: full validation using the provider-configured
+      // PEM/JWKS/instance-key chain. An empty set signals "use verifyTokenSignature()".
+      return doFullTokenValidation(request, response, token, tokenId,
+          displayableToken, displayableTokenId, Set.of());
+    }
+    // For issuers not in the static list, subclasses may resolve JWKS for a runtime-registered issuer.
+    // An empty result means "not applicable for this request" and the token is rejected.
+    // All other validation checks (expiry, audiences, nbf, token state) run identically to the static path.
+    final Set<URI> registeredIssuerJwks = resolveRegisteredIssuerJwks(token.getIssuer(), request);
+    if (!registeredIssuerJwks.isEmpty()) {
+      return doFullTokenValidation(request, response, token, tokenId,
+          displayableToken, displayableTokenId, registeredIssuerJwks);
+    }
+    handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, null);
+    return false;
+  }
+
+  /**
+   * Extension point for subclasses to resolve JWKS for an issuer that is registered at runtime
+   * (e.g., in {@code TrustedOidcIssuerService}) but is not in the static
+   * {@code jwt.expected.issuer} topology parameter.
+   *
+   * <p>Return semantics:
+   * <ul>
+   *   <li>Non-empty set — caller runs full token validation using only these JWKS for signature
+   *       verification; the provider-configured PEM/JWKS/instance-key chain is not consulted.</li>
+   *   <li>Empty set — not applicable for this request; caller rejects with 401.</li>
+   * </ul>
+   *
+   * <p>The default implementation always returns an empty set. Subclasses that support a runtime
+   * issuer registry should override this method, applying any request-context checks themselves,
+   * and return a non-empty set only when the issuer is found in the registry and
+   * its JWKS URI has been successfully resolved.
+   */
+  protected Set<URI> resolveRegisteredIssuerJwks(String issuer, HttpServletRequest request) {
+    return Set.of();
+  }
+
+  /**
+   * Runs the full token validation sequence (expiry, audiences, nbf, token state, signature)
+   * used by both the static-issuer path and the registered-issuer path.
+   *
+   * @param registeredIssuerJwks if non-empty, the signature is verified exclusively against these
+   *     JWKS URIs (resolved for the issuer from the runtime registry); if empty,
+   *     {@link #verifyTokenSignature(JWT)} is used instead (provider-configured PEM / JWKS /
+   *     instance-key chain).
+   */
+  private boolean doFullTokenValidation(final HttpServletRequest request, final HttpServletResponse response,
+      final JWT token, final String tokenId, final String displayableToken,
+      final String displayableTokenId, final Set<URI> registeredIssuerJwks)
+      throws IOException, ServletException {
+    try {
+      if (tokenIsStillValid(token)) {
+        if (validateAudiences(token)) {
+          Date nbf = token.getNotBeforeDate();
+          if (nbf == null || new Date().after(nbf)) {
+            final TokenMetadata tokenMetadata = tokenStateService == null ? null : tokenStateService.getTokenMetadata(tokenId);
+            if (isTokenEnabled(tokenMetadata)) {
+              if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
+                final boolean sigOk = registeredIssuerJwks.isEmpty()
+                    ? verifyTokenSignature(token)
+                    : verifyTokenSignatureWithJwks(token, registeredIssuerJwks);
+                if (sigOk) {
+                  markLastUsedAt(tokenId, tokenMetadata);
+                  return true;
                 } else {
-                  log.idleTimoutExceeded(token.getSubject(), displayableTokenId, idleTimeoutSeconds);
-                  handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, TOKEN_PREFIX + displayableTokenId + IDLE_TIMEOUT_POSTFIX);
+                  log.failedToVerifyTokenSignature(displayableToken, displayableTokenId);
+                  handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, null);
                 }
               } else {
-                log.disabledToken(displayableTokenId);
-                handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, TOKEN_PREFIX + displayableTokenId + DISABLED_POSTFIX);
+                log.idleTimoutExceeded(token.getSubject(), displayableTokenId, idleTimeoutSeconds);
+                handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED,
+                    TOKEN_PREFIX + displayableTokenId + IDLE_TIMEOUT_POSTFIX);
               }
             } else {
-              log.notBeforeCheckFailed();
-              handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST,
-                      "Bad request: the NotBefore check failed");
+              log.disabledToken(displayableTokenId);
+              handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED,
+                  TOKEN_PREFIX + displayableTokenId + DISABLED_POSTFIX);
             }
           } else {
-            log.failedToValidateAudience(displayableToken, displayableTokenId);
+            log.notBeforeCheckFailed();
             handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST,
-                    "Bad request: missing required token audience");
+                "Bad request: the NotBefore check failed");
           }
         } else {
-          log.tokenHasExpired(displayableToken, displayableTokenId);
-
-          // Explicitly evict the record of this token's signature verification (if present).
-          // There is no value in keeping this record for expired tokens, and explicitly removing them may prevent
-          // records for other valid tokens from being prematurely evicted from the cache.
-          removeSignatureVerificationRecord(token.toString());
-
-          handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
-
+          log.failedToValidateAudience(displayableToken, displayableTokenId);
+          handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST,
+              "Bad request: missing required token audience");
         }
-      } catch (UnknownTokenException e) {
-        log.unableToVerifyExpiration(e);
-        handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
+      } else {
+        log.tokenHasExpired(displayableToken, displayableTokenId);
+        // Explicitly evict the record of this token's signature verification (if present).
+        // There is no value in keeping this record for expired tokens, and explicitly removing them
+        // may prevent records for other valid tokens from being prematurely evicted from the cache.
+        removeSignatureVerificationRecord(token.toString());
+        handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
       }
-    } else {
-      handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, null);
+    } catch (UnknownTokenException e) {
+      log.unableToVerifyExpiration(e);
+      handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
     }
-
     return false;
   }
 
+  /**
+   * Verifies the token's signature against the given JWKS URIs.
+   * Uses the filter's configured signature algorithm and JWS type verifier.
+   */
+  private boolean verifyTokenSignatureWithJwks(final JWT token, final Set<URI> jwksUrls) {
+    final String serializedJWT = token.toString();
+    if (hasSignatureBeenVerified(serializedJWT)) {
+      return true;
+    }
+    try {
+      final boolean verified = authority.verifyToken(token, jwksUrls, expectedSigAlg, typeVerifier);
+      if (verified) {
+        recordSignatureVerification(serializedJWT);
+      }
+      return verified;
+    } catch (TokenServiceException e) {
+      log.unableToVerifyToken(e);
+      return false;
+    }
+  }
+
   private boolean isTokenEnabled(TokenMetadata tokenMetadata) throws UnknownTokenException {
     return tokenMetadata == null ? true : tokenMetadata.isEnabled();
   }
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 59cb219e7..5109a2c09 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
@@ -22,6 +22,9 @@ import org.apache.commons.lang3.tuple.Pair;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
 import org.apache.knox.gateway.services.security.token.TokenUtils;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
@@ -42,11 +45,14 @@ import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.text.ParseException;
 import java.util.Base64;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
 import java.util.Set;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -58,7 +64,6 @@ import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
 import static org.apache.knox.gateway.util.AuthFilterUtils.DEFAULT_AUTH_UNAUTHENTICATED_PATHS_PARAM;
 
 public class JWTFederationFilter extends AbstractJWTFilter {
-
   private static final JWTMessages LOGGER = MessagesFactory.get( JWTMessages.class );
   /* A semicolon separated list of paths that need to bypass authentication */
   public static final String JWT_UNAUTHENTICATED_PATHS_PARAM = "jwt.unauthenticated.path.list";
@@ -69,6 +74,16 @@ public class JWTFederationFilter extends AbstractJWTFilter {
   public static final String CLIENT_ASSERTION_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
   public static final String CLIENT_ASSERTION_TYPE = "client_assertion_type";
   public static final String CLIENT_ASSERTION = "client_assertion";
+  // RFC 8693 constants
+  public static final String TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
+  public static final String SUBJECT_TOKEN = "subject_token";
+  public static final String ACTOR_TOKEN = "actor_token";
+  public static final String SUBJECT_TOKEN_TYPE = "subject_token_type";
+  public static final String ACTOR_TOKEN_TYPE = "actor_token_type";
+  // RFC 8693 section 3 token type identifiers. Only JWT-family types are supported for exchange;
+  // Knox issues JWT access tokens, so the access_token URN is accepted as an alias for jwt.
+  public static final String TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt";
+  public static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
 
   public enum TokenType {
     JWT, Passcode, TokenExchange;
@@ -245,6 +260,10 @@ public class JWTFederationFilter extends AbstractJWTFilter {
     if (scope != null) {
       request.setAttribute(KnoxIDFConstants.SCOPE_ATTRIBUTE, token.getClaim(scope));
     }
+    final String issuer = token.getIssuer();
+    if (issuer != null) {
+      request.setAttribute(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE, issuer);
+    }
   }
 
   private void validateClientID(HttpServletRequest request, String tokenValue) {
@@ -358,7 +377,7 @@ public class JWTFederationFilter extends AbstractJWTFilter {
         } else if (REFRESH_TOKEN.equals(grantType)) {
           // refresh_token flow: the refresh_token parameter contains the actual token
           return getClientTokenFromParams(unwrappedRequest, REFRESH_TOKEN_PARAM);
-        } else if (TokenExchangeHandler.TOKEN_EXCHANGE.equals(grantType)) {
+        } else if (TOKEN_EXCHANGE.equals(grantType)) {
           // RFC 8693 token exchange: signal it via the token type. doFilter routes this to
           // TokenExchangeHandler, which reads subject_token/actor_token from the unwrapped request.
           return Pair.of(TokenType.TokenExchange, null);
@@ -453,6 +472,33 @@ public class JWTFederationFilter extends AbstractJWTFilter {
     return null;
   }
 
+  @Override
+  protected Set<URI> resolveRegisteredIssuerJwks(String issuer, HttpServletRequest request) {
+    if (!TOKEN_EXCHANGE.equals(request.getParameter(GRANT_TYPE))) {
+      return Set.of();
+    }
+    final GatewayServices gws = (GatewayServices)
+        request.getServletContext().getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+    if (gws != null) {
+      final TrustedOidcIssuerService issuerSvc = gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE);
+      // isDynamicJwks() is the combined guard: true only if the issuer is both registered as
+      // trusted AND configured for dynamic JWKS discovery. If the issuer is not registered, or
+      // registered without dynamic JWKS, it is not actionable through this path.
+      if (issuerSvc != null && issuerSvc.isDynamicJwks(issuer)) {
+        // resolveJwksUri() performs OIDC discovery
+        final Optional<String> jwksUri = issuerSvc.resolveJwksUri(issuer);
+        if (jwksUri.isPresent()) {
+          try {
+            return Set.of(new URI(jwksUri.get()));
+          } catch (URISyntaxException e) {
+            LOGGER.unableToVerifyToken(e);
+          }
+        }
+      }
+    }
+    return Set.of();
+  }
+
   @Override
   protected void handleValidationError(HttpServletRequest request, HttpServletResponse response, int status,
                                        String error) throws IOException {
diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
index 99bd3de5f..9fe7558f0 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
@@ -39,6 +39,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.ACTOR_TOKEN_TYPE;
+import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.SUBJECT_TOKEN_TYPE;
+import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TOKEN_TYPE_ACCESS_TOKEN;
+import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TOKEN_TYPE_JWT;
 /**
  * Handles RFC 8693 (OAuth 2.0 Token Exchange) requests on behalf of {@link JWTFederationFilter}.
  *
@@ -56,16 +60,6 @@ import java.util.Set;
  */
 class TokenExchangeHandler {
 
-  public static final String TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
-  public static final String SUBJECT_TOKEN = "subject_token";
-  public static final String SUBJECT_TOKEN_TYPE = "subject_token_type";
-  public static final String ACTOR_TOKEN = "actor_token";
-  public static final String ACTOR_TOKEN_TYPE = "actor_token_type";
-  // RFC 8693 section 3 token type identifiers. Only JWT-family types are supported for exchange;
-  // Knox issues JWT access tokens, so the access_token URN is accepted as an alias for jwt.
-  public static final String TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt";
-  public static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
-
   private final JWTFederationFilter filter;
 
   TokenExchangeHandler(JWTFederationFilter filter) {
@@ -88,9 +82,9 @@ class TokenExchangeHandler {
     // unchanged.
     final HttpServletRequest bodyRequest = ServletRequestUtils.unwrapHttpServletRequest(request);
 
-    final String subjectTokenValue = bodyRequest.getParameter(SUBJECT_TOKEN);
+    final String subjectTokenValue = bodyRequest.getParameter(JWTFederationFilter.SUBJECT_TOKEN);
     final String subjectTokenType = bodyRequest.getParameter(SUBJECT_TOKEN_TYPE);
-    final String actorTokenValue = bodyRequest.getParameter(ACTOR_TOKEN);
+    final String actorTokenValue = bodyRequest.getParameter(JWTFederationFilter.ACTOR_TOKEN);
     final String actorTokenType = bodyRequest.getParameter(ACTOR_TOKEN_TYPE);
     final boolean hasActorToken = actorTokenValue != null && !actorTokenValue.isEmpty();
     final boolean hasActorTokenType = actorTokenType != null && !actorTokenType.isEmpty();
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java
new file mode 100644
index 000000000..6239096a3
--- /dev/null
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java
@@ -0,0 +1,737 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.provider.federation;
+
+import com.nimbusds.jose.proc.JOSEObjectTypeVerifier;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter;
+import org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter;
+
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
+import org.apache.knox.gateway.services.security.token.JWTokenAuthority;
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import java.net.URI;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.Set;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+
+/**
+ * Tests for two JWTFederationFilter extensions added for Knox IDF delegation.
+ *
+ * <p><b>Change 1 — TOKEN_ISS_ATTRIBUTE</b> ({@link #testIssAttributeSetAfterValidation}):
+ * After successful Bearer JWT validation the token's {@code iss} claim is stored as a request
+ * attribute for use by admin endpoint handlers (per-cluster scope limiting).
+ *
+ * <p><b>Change 2 — Dynamic JWKS for token-exchange</b>: If a token's issuer is absent from
+ * the static {@code jwt.expected.issuer} list, the filter consults
+ * {@code TrustedOidcIssuerService} via {@code resolveRegisteredIssuerJwks}.
+ * If the issuer is registered with {@code isDynamicJwks=true}, the dynamically resolved JWKS
+ * URI is used exclusively for signature verification. All other validation (expiry, audiences,
+ * nbf, token state) runs via the same {@code doFullTokenValidation} helper as the static path.
+ *
+ * <p> NOTE: Tests are simplified to single-token form (subject_token only) wherever
+ * actor_token was not the subject of the test. Only two tests retain both tokens:
+ * {@link #testDynamicIssuerAllowedActorExternal}, which specifically tests the actor_token
+ * dynamic JWKS path, and {@link #testDynamicPathUsesRegistryJwksNotStaticJwks}, which
+ * verifies that both tokens are validated against the correct JWKS source independently.
+ *
+ * <p>NOTE: We do not test the specific failure modes {@code isTokenEnabled} or
+ * {@code isIdleTimeoutLimitNotExceeded} in the dynamic JWKS path. It would require more complex
+ * {@code TokenStateService} setup; without TSS they return true
+ * trivially for both paths, same as the static-issuer path covered by the Knox TSS suite.
+ *
+ * <p><b>Filter configuration:</b> the default {@link TestFilterConfig} sets
+ * {@code jwt.expected.issuer} to {@value AbstractJWTFilter#JWT_DEFAULT_ISSUER} only. No static
+ * JWKS URLs are configured unless a test explicitly sets {@link JWTFederationFilter#JWKS_URL}.
+ */
+public class JWTFederationFilterTokenExchangeTest extends AbstractJWTFilterTest {
+
+  static final String EXTERNAL_ISSUER = "https://external.oidc.example.com";
+  static final String KNOX_ISSUER = AbstractJWTFilter.JWT_DEFAULT_ISSUER;
+  static final String DYNAMIC_JWKS_URI = "https://external.oidc.example.com/.well-known/jwks.json";
+
+  @Before
+  public void setUp() {
+    handler = new TestJWTFederationFilter();
+    ((TestJWTFederationFilter) handler).setTokenService(new TestJWTokenAuthority(publicKey));
+  }
+
+  @Override
+  protected String getAudienceProperty() {
+    return JWTFederationFilter.KNOX_TOKEN_AUDIENCES;
+  }
+
+  @Override
+  protected String getVerificationPemProperty() {
+    return JWTFederationFilter.TOKEN_VERIFICATION_PEM;
+  }
+
+  @Override
+  protected void setTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize());
+  }
+
+  @Override
+  protected void setGarbledTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " ljm" + jwt.serialize());
+  }
+
+  // ---------------------------------------------------------------------------
+  // Dynamic registry path — success
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Subject token from EXTERNAL_ISSUER (not in static list); no actor token. The authority mock
+   * verifies the dynamic path calls verifyToken with the resolved JWKS URI, configured sig-alg,
+   * and type-verifier.
+   */
+  @Test
+  public void testDynamicIssuerAllowedSubjectExternal() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final Capture<JWT> capturedDynamicJwt = EasyMock.newCapture();
+
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedDynamicJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("Filter chain should proceed", chain.doFilterCalled);
+    Assert.assertEquals(EXTERNAL_ISSUER, capturedDynamicJwt.getValue().getIssuer());
+    EasyMock.verify(mockAuth, issuerSvc);
+  }
+
+  /**
+   * Actor token from EXTERNAL_ISSUER (dynamic path); subject token from KNOX_ISSUER (static
+   * path). This is the primary K8s SA delegation scenario: the acting service carries a
+   * projected SA token with a dynamically registered issuer; the subject carries a Knox-issued
+   * token. The authority mock verifies the same argument contract as the previous test.
+   */
+  @Test
+  public void testDynamicIssuerAllowedActorExternal() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(KNOX_ISSUER, "end-user",
+        new Date(System.currentTimeMillis() + 60000));
+    final SignedJWT actorJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final Capture<JWT> capturedDynamicJwt = EasyMock.newCapture();
+    final Capture<JWT> capturedStaticJwt = EasyMock.newCapture();
+
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(EasyMock.capture(capturedStaticJwt))).andReturn(true).once();
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedDynamicJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), // configured sig-alg
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))       // filter-configured type verifier
+        .andReturn(true).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("Filter chain should proceed", chain.doFilterCalled);
+    Assert.assertEquals(EXTERNAL_ISSUER, capturedDynamicJwt.getValue().getIssuer());
+    Assert.assertEquals(KNOX_ISSUER, capturedStaticJwt.getValue().getIssuer());
+    EasyMock.verify(mockAuth, issuerSvc);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Dynamic registry path — signature, expiry, nbf, audience failures
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Dynamic JWKS resolved; authority.verifyToken returns false for that URI. The authority
+   * mock verifies the exact JWKS URI, configured sig-alg ("RS256"), and JOSEObjectTypeVerifier
+   * type were passed to authority.verifyToken. Any other authority call (static JWKS, instance
+   * key) would fail the strict mock.
+   */
+  @Test
+  public void testSignatureVerificationFails() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.anyObject(JWT.class),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(false).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  /**
+   * Dynamic JWKS resolved, but the token is expired. The strict mock with {@code .times(0, 1)}
+   * allows JWKS signature verification to happen 0 or 1 times (validation order is not
+   * guaranteed), so the "Token has expired" rejection is the guaranteed outcome. If the JWKS
+   * call occurs, the captured JWT must have EXTERNAL_ISSUER. {@code verify(issuerSvc)} confirms
+   * the dynamic path was entered before the expiry check.
+   */
+  @Test
+  public void testExpiredTokenRejectedOnDynamicPath() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT expiredJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() - 60000));
+
+    final Capture<JWT> capturedJwt = EasyMock.newCapture();
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).times(0, 1);
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        expiredJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    if (capturedJwt.hasCaptured()) {
+      Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer());
+    }
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  /**
+   * Dynamic JWKS resolved, but the token's NotBefore is in the future. The strict mock with
+   * {@code .times(0, 1)} allows JWKS signature verification to happen 0 or 1 times (validation
+   * order is not guaranteed), so the "NotBefore check failed" rejection is the guaranteed
+   * outcome. If the JWKS call occurs, the captured JWT must have EXTERNAL_ISSUER.
+   */
+  @Test
+  public void testFutureNbfRejectedOnDynamicPath() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final Date futureNbf = new Date(System.currentTimeMillis() + 300000);
+    final Date futureExpiry = new Date(System.currentTimeMillis() + 600000);
+    final SignedJWT nbfJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", futureExpiry, futureNbf, privateKey, "RS256");
+
+    final Capture<JWT> capturedJwt = EasyMock.newCapture();
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).times(0, 1);
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        nbfJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: the NotBefore check failed");
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    if (capturedJwt.hasCaptured()) {
+      Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer());
+    }
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  /**
+   * Dynamic JWKS resolved, but the token's audience does not match the required audience. The
+   * strict mock with {@code .times(0, 1)} allows JWKS signature verification to happen 0 or 1
+   * times (validation order is not guaranteed), so the audience rejection is the guaranteed
+   * outcome. If the JWKS call occurs, the captured JWT must have EXTERNAL_ISSUER.
+   */
+  @Test
+  public void testAudienceMismatchRejectedOnDynamicPath() throws Exception {
+    final Properties props = getProperties();
+    props.setProperty(JWTFederationFilter.KNOX_TOKEN_AUDIENCES, "required-audience");
+    handler.init(new TestFilterConfig(props));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000)); // default aud="bar", not "required-audience"
+
+    final Capture<JWT> capturedJwt = EasyMock.newCapture();
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).times(0, 1);
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: missing required token audience");
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    if (capturedJwt.hasCaptured()) {
+      Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer());
+    }
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Token rejected — issuer does not qualify for dynamic JWKS verification
+  // ---------------------------------------------------------------------------
+
+  /**
+   * The issuer is not registered in the dynamic registry; isDynamicJwks returns false. The
+   * filter rejects with 401. resolveJwksUri is not expected on the strict mock — any call to
+   * it would fail verify(), proving no HTTP fetch was attempted (SSRF prevention).
+   */
+  @Test
+  public void testUntrustedIssuerRejectedNoHttpCall() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(false).once();
+    // resolveJwksUri not expected — any call fails verify(), proving no HTTP fetch attempted
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(issuerSvc, response);
+  }
+
+  /**
+   * TrustedOidcIssuerService is null. The hook returns without calling any service method.
+   * EXTERNAL_ISSUER is not in expectedIssuers, so the filter rejects.
+   */
+  @Test
+  public void testServiceUnavailable() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(null).anyTimes();
+    EasyMock.replay(gws);
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildServletContext(gws));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(response);
+  }
+
+  /**
+   * Bearer JWT from EXTERNAL_ISSUER with no grant_type — not a token-exchange request. The
+   * hook checks grant_type first and returns without consulting the registry. EXTERNAL_ISSUER
+   * is not in expectedIssuers, so the filter rejects. The strict mock proves no service method
+   * was called.
+   */
+  @Test
+  public void testNonTokenExchangeRegistryIssuerRejected() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService strictIssuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize()).anyTimes();
+    EasyMock.expect(request.getServletContext())
+        .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes();
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(strictIssuerSvc);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Static-issuer failures do not fall through to the dynamic path
+  // ---------------------------------------------------------------------------
+
+  /**
+   * KNOX_ISSUER is in expectedIssuers. Signature verification on the static path fails.
+   * The strict issuerSvc mock with no expectations proves isDynamicJwks was never called —
+   * the static-issuer failure does not trigger the dynamic registry.
+   */
+  @Test
+  public void testStaticIssuerSignatureFailureDoesNotFallToDynamic() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(EasyMock.anyObject(JWT.class))).andReturn(false).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService strictIssuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize()).anyTimes();
+    EasyMock.expect(request.getServletContext())
+        .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes();
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(mockAuth, strictIssuerSvc, response);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Static JWKS and dynamic registry both configured
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Static JWKS (knox.token.jwks.url) and dynamic registry are both configured. The authority
+   * mock is strict with distinct URI-set expectations per token: the external-issuer token uses
+   * the dynamic JWKS URI exclusively (never the static JWKS), and the KNOX_ISSUER token uses
+   * the static JWKS. The eq() on sig-alg verifies the configured value ("RS256") is passed to
+   * authority.verifyToken on the dynamic path.
+   */
+  @Test
+  public void testDynamicPathUsesRegistryJwksNotStaticJwks() throws Exception {
+    final String staticJwksUrl = "https://static.jwks.example.com/jwks";
+    final String dynamicJwksUrl = "https://dynamic.jwks.example.com/jwks";
+    final Set<URI> staticJwks = Set.of(new URI(staticJwksUrl));
+    final Set<URI> dynamicJwks = Set.of(new URI(dynamicJwksUrl));
+
+    final Properties props = getProperties();
+    props.setProperty(JWTFederationFilter.JWKS_URL, staticJwksUrl);
+    handler.init(new TestFilterConfig(props));
+
+    final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.anyObject(JWT.class), EasyMock.eq(dynamicJwks), // external-issuer token: dynamic JWKS only
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).once();
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.anyObject(JWT.class), EasyMock.eq(staticJwks),  // Knox-issuer token: static JWKS
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+    final SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+    EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(dynamicJwksUrl)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue(chain.doFilterCalled);
+    EasyMock.verify(mockAuth, issuerSvc);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Existing behavior unaffected by the new hook
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Token-exchange request with a Knox-issuer (static) subject token and no actor token. The
+   * strict issuerSvc mock with no expectations proves isDynamicJwks is never called for a
+   * static-issuer token, even in a token-exchange grant.
+   */
+  @Test
+  public void testTokenExchangeWithStaticIssuerSubjectSucceeds() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService strictIssuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildContextWithIssuerService(strictIssuerSvc));
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("Filter chain should proceed", chain.doFilterCalled);
+    EasyMock.verify(strictIssuerSvc);
+  }
+
+  /**
+   * Bearer JWT from KNOX_ISSUER (in static expectedIssuers). validateToken() returns from the
+   * static-issuer branch before resolveRegisteredIssuerJwks is reached. The strict issuerSvc
+   * mock with no expectations proves the hook was not called: any service method call would
+   * throw immediately.
+   */
+  @Test
+  public void testNonTokenExchangeGrantUnaffected() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService strictIssuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize()).anyTimes();
+    EasyMock.expect(request.getServletContext())
+        .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes();
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue(chain.doFilterCalled);
+    EasyMock.verify(strictIssuerSvc);
+  }
+
+  // ---------------------------------------------------------------------------
+  // TOKEN_ISS_ATTRIBUTE — separate concern from JWKS logic
+  // ---------------------------------------------------------------------------
+
+  /**
+   * After successful Bearer JWT validation, addKnoxIDFAttributes() stores TOKEN_ISS_ATTRIBUTE
+   * on the request. Used by admin endpoint handlers for per-cluster scope limiting.
+   */
+  @Test
+  public void testIssAttributeSetAfterValidation() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final Map<String, Object> capturedAttrs = new HashMap<>();
+    final HttpServletRequest underlying = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(underlying.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(underlying.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize()).anyTimes();
+    EasyMock.replay(underlying);
+
+    final HttpServletRequest request = new HttpServletRequestWrapper(underlying) {
+      @Override
+      public void setAttribute(String name, Object o) {
+        capturedAttrs.put(name, o);
+      }
+
+      @Override
+      public Object getAttribute(String name) {
+        return capturedAttrs.get(name);
+      }
+    };
+
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue(chain.doFilterCalled);
+    Assert.assertEquals(KNOX_ISSUER, capturedAttrs.get(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE));
+  }
+
+  // ---------------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------------
+
+  private ServletContext buildContextWithIssuerService(TrustedOidcIssuerService issuerSvc) {
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(issuerSvc).anyTimes();
+    EasyMock.replay(gws);
+    return buildServletContext(gws);
+  }
+
+  private ServletContext buildServletContext(GatewayServices gws) {
+    final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+    EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(gws).anyTimes();
+    EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE))
+        .andReturn("jwt-test-topology").anyTimes();
+    EasyMock.replay(ctx);
+    return ctx;
+  }
+
+  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, ServletContext ctx) {
+    final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes();
+    // ACTOR_TOKEN not mocked — niceMock returns null, making actor_token absent
+    EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes();
+    return request;
+  }
+
+  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, String actorToken,
+      ServletContext ctx) {
+    final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(actorToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes();
+    EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes();
+    return request;
+  }
+
+}
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
index 665f6fc9d..1dad10b28 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
@@ -64,7 +64,7 @@ public class JWTFederationFilterTokenExchangeRoutingTest {
   @Test
   public void testTokenExchangeGrantRoutesToHandler() throws Exception {
     // grant_type in the body (unwrapped), no Authorization header
-    final HttpServletRequest request = wrapped(bodyRequest(TokenExchangeHandler.TOKEN_EXCHANGE, null));
+    final HttpServletRequest request = wrapped(bodyRequest(JWTFederationFilter.TOKEN_EXCHANGE, null));
 
     filter.doFilter(request, response, chain);
 
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
index 49561ad66..a5296b349 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
@@ -49,8 +49,8 @@ import java.util.Set;
  */
 public class TokenExchangeHandlerTest {
 
-  private static final String JWT_TYPE = TokenExchangeHandler.TOKEN_TYPE_JWT;
-  private static final String ACCESS_TOKEN_TYPE = TokenExchangeHandler.TOKEN_TYPE_ACCESS_TOKEN;
+  private static final String JWT_TYPE = JWTFederationFilter.TOKEN_TYPE_JWT;
+  private static final String ACCESS_TOKEN_TYPE = JWTFederationFilter.TOKEN_TYPE_ACCESS_TOKEN;
   private static final String SAML2_TYPE = "urn:ietf:params:oauth:token-type:saml2";
 
   private RecordingFilter filter;
@@ -193,10 +193,10 @@ public class TokenExchangeHandlerTest {
   private HttpServletRequest request(String subjectToken, String subjectTokenType,
                                      String actorToken, String actorTokenType) {
     final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
-    EasyMock.expect(request.getParameter(TokenExchangeHandler.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
-    EasyMock.expect(request.getParameter(TokenExchangeHandler.SUBJECT_TOKEN_TYPE)).andReturn(subjectTokenType).anyTimes();
-    EasyMock.expect(request.getParameter(TokenExchangeHandler.ACTOR_TOKEN)).andReturn(actorToken).anyTimes();
-    EasyMock.expect(request.getParameter(TokenExchangeHandler.ACTOR_TOKEN_TYPE)).andReturn(actorTokenType).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE)).andReturn(subjectTokenType).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(actorToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE)).andReturn(actorTokenType).anyTimes();
     EasyMock.replay(request);
     return request;
   }
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 99ba8b5e3..d757b573f 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
@@ -48,6 +48,7 @@ public interface KnoxIDFConstants {
     String PKCE_METHOD_PLAIN = "plain";
 
     String TOKEN_ID_ATTRIBUTE = "X-Token-Id";
+    String TOKEN_ISS_ATTRIBUTE = "X-Token-Iss";
     String SCOPE_ATTRIBUTE = "X-Token-Scope";
 
     String FEDERATED_IDENTITY_ID = "federated_identity_id";


(knox) 18/37: KNOX-3414: E2E tests now adjusted to cover the previous Tier 1/2/3 bug fixes and changes

Posted by sm...@apache.org.
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 c8af244e46cadf2e1caddeb4989d92677a1dc41d
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 14:53:40 2026 +0200

    KNOX-3414: E2E tests now adjusted to cover the previous Tier 1/2/3 bug fixes and changes
---
 .../build/conf/topologies/knoxidf-ldap.xml         |  8 +++
 .../build/conf/topologies/knoxidf-token.xml        |  7 ++
 .github/workflows/tests/test_knoxidf.py            | 75 ++++++++++------------
 3 files changed, 49 insertions(+), 41 deletions(-)

diff --git a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
index f3f795467..e7c3cd45d 100644
--- a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
+++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
@@ -66,6 +66,14 @@
             <name>knoxidf.client.registration.anonymous.allowed</name>
             <value>true</value>
         </param>
+        <param>
+            <!-- Skipping the user-consent screen is a server-side deployment decision, never a
+                 client-supplied request parameter (a client must not be able to bypass consent by
+                 sending auto_consent=true). This sample/CI topology opts in so the automated
+                 authorization-code flow tests can complete without driving the consent UI. -->
+            <name>knoxidf.auto.consent.enabled</name>
+            <value>true</value>
+        </param>
         <param>
             <name>token.exchange.topology.name</name>
             <value>knoxidf-token</value>
diff --git a/.github/workflows/build/conf/topologies/knoxidf-token.xml b/.github/workflows/build/conf/topologies/knoxidf-token.xml
index fdc11d62b..a1858b509 100644
--- a/.github/workflows/build/conf/topologies/knoxidf-token.xml
+++ b/.github/workflows/build/conf/topologies/knoxidf-token.xml
@@ -27,6 +27,13 @@
             <name>knoxidf.knox.token.limit.per.user</name>
             <value>-1</value>
         </param>
+        <param>
+            <!-- Skipping the user-consent screen is a server-side deployment decision, never a
+                 client-supplied request parameter. Kept in sync with knoxidf-ldap.xml so the
+                 KNOXIDF service consents consistently across both topologies. -->
+            <name>knoxidf.auto.consent.enabled</name>
+            <value>true</value>
+        </param>
     </service>
     <service>
         <role>KNOXTOKEN</role>
diff --git a/.github/workflows/tests/test_knoxidf.py b/.github/workflows/tests/test_knoxidf.py
index cbbbacaf2..56df6f312 100644
--- a/.github/workflows/tests/test_knoxidf.py
+++ b/.github/workflows/tests/test_knoxidf.py
@@ -26,7 +26,6 @@ from common_utils import (
     knox_get,
     knox_post,
     get_token_claim,
-    get_token_id_display_text,
 )
 
 
@@ -70,9 +69,10 @@ class TestKnoxIDF(unittest.TestCase):
             ["authorization_code", "refresh_token"],
         )
         self.assertEqual(config.get("id_token_signing_alg_values_supported"), ["RS256"])
+        # DEFAULT_SCOPES is an ImmutableSet, so discovery emits it in insertion order.
         self.assertEqual(
             config.get("scopes_supported"),
-            ["openid", "email", "profile", "offline_access"],
+            ["openid", "profile", "email", "offline_access"],
         )
 
     def test_client_credentials_flow(self):
@@ -107,14 +107,14 @@ class TestKnoxIDF(unittest.TestCase):
         # 1. Register client
         client_id, client_secret = self._register_test_client()
 
-        # 2. Authorize (with Basic Auth for the user 'guest')
+        # 2. Authorize (with Basic Auth for the user 'guest'). Consent is auto-granted by the
+        # server (knoxidf.auto.consent.enabled=true in the topology), not by any client parameter.
         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")
 
@@ -139,8 +139,7 @@ class TestKnoxIDF(unittest.TestCase):
 
         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}")
+        print(f"Refresh token knox.id: {get_token_claim(refresh_token, 'knox.id')}")
 
         # 4. Refresh the token (rotation)
         print("Refreshing token...")
@@ -174,11 +173,12 @@ class TestKnoxIDF(unittest.TestCase):
             verify=False,
             headers={"Accept": "application/json"},
         )
+        # On the refresh_token grant, JWTFederationFilter pulls the refresh_token from the
+        # request body and validates it as an auth credential before TokenResource runs.
+        # Rotation revoked this token, so that filter-level check fails and returns 401
+        # Unauthorized (a plain sendError HTML page, not the JSON invalid_grant body the
+        # resource would emit) -- the request never reaches handleRefreshToken.
         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):
         """
@@ -198,7 +198,6 @@ class TestKnoxIDF(unittest.TestCase):
             "redirect_uri": "http://localhost/callback",
             "scope": "openid",
             "state": "pkce_state",
-            "auto_consent": "true",
             "code_challenge": code_challenge,
             "code_challenge_method": "S256"
         }
@@ -219,44 +218,39 @@ class TestKnoxIDF(unittest.TestCase):
         tokens = response.json()
         self.assertIn("access_token", tokens)
 
-    def test_authorization_code_flow_pkce_plain(self):
+    def test_authorization_code_flow_pkce_plain_rejected(self):
         """
-        Test OIDC Authorization Code Flow with PKCE (plain).
+        The 'plain' PKCE code_challenge_method offers no protection and is rejected: the authorize
+        endpoint must refuse to issue a code, returning invalid_request rather than redirecting.
         """
         # 1. Register client
-        client_id, client_secret = self._register_test_client()
+        client_id, _ = self._register_test_client()
 
-        # 2. PKCE Setup
-        code_verifier = "some-plain-verifier"
-        code_challenge = code_verifier
+        # 2. PKCE Setup ('plain': challenge == verifier)
+        code_challenge = "some-plain-verifier"
 
-        # 3. Authorize
+        # 3. Authorize with the unsupported method -> 400 invalid_request, no redirect
+        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/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)
+        response = knox_get(
+            auth_url,
+            params=params,
+            auth=(self.username, self.password),
+            verify=False,
+            allow_redirects=False,
+        )
+        self.assertEqual(response.status_code, 400)
+        error_info = response.json()
+        self.assertEqual(error_info["error"], "invalid_request")
+        self.assertIn("S256", error_info["error_description"])
 
     def test_authorization_code_flow_pkce_failure(self):
         """
@@ -273,7 +267,6 @@ class TestKnoxIDF(unittest.TestCase):
             "redirect_uri": "http://localhost/callback",
             "scope": "openid",
             "state": "pkce_fail",
-            "auto_consent": "true",
             "code_challenge": code_challenge,
             "code_challenge_method": "S256"
         }
@@ -281,7 +274,7 @@ class TestKnoxIDF(unittest.TestCase):
 
         token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
 
-        # 1. Invalid verifier
+        # 1. Invalid verifier -> invalid_grant (HTTP 400)
         data = {
             "grant_type": "authorization_code",
             "code": code,
@@ -291,13 +284,13 @@ class TestKnoxIDF(unittest.TestCase):
             "code_verifier": "wrong-verifier"
         }
         response = knox_post(token_url, data=data)
-        self.assertEqual(response.status_code, 401)
+        self.assertEqual(response.status_code, 400)
         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
+        # A code that fails validation is not consumed, but fetch a fresh one for a clean scenario.
         code = self._authorize_get_code(params)
 
-        # 2. Missing verifier
+        # 2. Missing verifier -> invalid_grant (HTTP 400)
         data = {
             "grant_type": "authorization_code",
             "code": code,
@@ -306,7 +299,7 @@ class TestKnoxIDF(unittest.TestCase):
             "client_secret": client_secret
         }
         response = knox_post(token_url, data=data)
-        self.assertEqual(response.status_code, 401)
+        self.assertEqual(response.status_code, 400)
         self.assertIn("Missing code_verifier", response.json()["error_description"])
 
     def _register_test_client(self):


(knox) 25/37: KNOX-3414: fail closed when a federated-OP client-secret alias is unresolvable (review finding M4)

Posted by sm...@apache.org.
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 1473102923fa25c21294c75e4938d8c0c83ff93a
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:28:13 2026 +0200

    KNOX-3414: fail closed when a federated-OP client-secret alias is unresolvable (review finding M4)
    
    resolveClientSecret returned null when a configured client-secret alias could not
    be resolved. That null flowed into the token-request form as a
    BasicNameValuePair, which the URL encoder serialized to a literal
    client_secret=null and sent to the OP -- the opposite of fail-closed: instead of
    refusing, Knox made a back-channel call with a bogus secret.
    
    Make it truly fail closed: requireResolvedAliasSecret throws
    ClientSecretResolutionException when a declared alias resolves to nothing (null
    or empty), and authCallback catches it and returns a clear server_error before
    any HTTP request to the OP. The plaintext clientSecret fallback (no alias
    configured) is unchanged for backward compatibility.
    
    Covered by AuthorizeResourceClientSecretResolutionTest.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/AuthorizeResource.java | 46 ++++++++++++++---
 ...uthorizeResourceClientSecretResolutionTest.java | 59 ++++++++++++++++++++++
 2 files changed, 98 insertions(+), 7 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index 9747d3c63..4b6d7ec7a 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -300,7 +300,14 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         federatedOpConfigurationStore.remove(state);
         final String expectedNonce = federatedNonceStore.get(state);
         federatedNonceStore.remove(state);
-        final Pair<String, String> federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
+        final Pair<String, String> federatedTokens;
+        try {
+            federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
+        } catch (ClientSecretResolutionException e) {
+            // A configured client-secret alias could not be resolved. This is a server-side
+            // misconfiguration, not a client error, and we deliberately never made the OP call.
+            return error("server_error", e.getMessage());
+        }
         if (StringUtils.isBlank(federatedTokens.getLeft())) {
             return error("invalid_request", "Federated OP did not return an id_token");
         }
@@ -490,25 +497,50 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
      * preferred, secure source and takes precedence: when it resolves to a value, that value is
      * used and the plaintext {@code clientSecret} topology param is never consulted. The plaintext
      * param remains supported as a fallback only when no alias is configured, so existing
-     * deployments keep working. If an alias is configured but cannot be resolved we fail closed
-     * (return {@code null}) rather than silently leaking through to the plaintext param, so a
-     * misconfigured alias surfaces as an auth failure instead of masking the intended secure source.
+     * deployments keep working. If an alias is configured but cannot be resolved we fail closed by
+     * throwing {@link ClientSecretResolutionException} rather than returning {@code null} (which the
+     * form encoder would have serialized to a literal {@code client_secret=null} sent to the OP) or
+     * silently leaking through to the plaintext param. The caller aborts the exchange before any
+     * HTTP request, so a misconfigured alias surfaces as a clear error instead of a bogus OP call.
      */
     private String resolveClientSecret(final FederatedOpConfiguration opConfig) {
         final String alias = opConfig.getClientSecretAlias();
         if (StringUtils.isBlank(alias)) {
             return opConfig.getClientSecret();
         }
+        char[] secret = null;
         try {
             final AliasService aliasService = getGatewayServices().getService(ServiceType.ALIAS_SERVICE);
             String clusterName = (String) servletContext.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE);
             if (StringUtils.isBlank(clusterName)) {
                 clusterName = AliasService.NO_CLUSTER_NAME;
             }
-            final char[] secret = aliasService.getPasswordFromAliasForCluster(clusterName, alias, false);
-            return secret == null ? null : new String(secret);
+            secret = aliasService.getPasswordFromAliasForCluster(clusterName, alias, false);
         } catch (AliasServiceException e) {
-            return null;
+            // Fall through to the fail-closed check below; an alias was configured but its lookup failed.
+            secret = null;
+        }
+        return requireResolvedAliasSecret(alias, secret);
+    }
+
+    /**
+     * Fail-closed guard for an explicitly configured client-secret alias: returns the resolved secret
+     * or throws {@link ClientSecretResolutionException} when it is absent/empty. Package-private and
+     * pure so the fail-closed decision is unit-testable without a live {@code AliasService}.
+     */
+    static String requireResolvedAliasSecret(final String alias, final char[] secret) {
+        if (secret == null || secret.length == 0) {
+            throw new ClientSecretResolutionException(
+                    "Federated OP client secret alias '" + alias + "' is configured but could not be resolved; "
+                            + "refusing to contact the OP without the intended secret");
+        }
+        return new String(secret);
+    }
+
+    /** Signals that a configured client-secret alias could not be resolved; the exchange must abort. */
+    static final class ClientSecretResolutionException extends RuntimeException {
+        ClientSecretResolutionException(final String message) {
+            super(message);
         }
     }
 
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.java
new file mode 100644
index 000000000..f67937c61
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.apache.knox.gateway.service.knoxidf.AuthorizeResource.ClientSecretResolutionException;
+import org.junit.Test;
+
+/**
+ * Verifies the fail-closed handling of a configured federated-OP client-secret alias (review
+ * finding M4). When an alias is declared but resolves to nothing, the token exchange must abort
+ * with a clear error and never contact the OP -- previously an unresolvable alias yielded a null
+ * secret that the form encoder serialized to a literal {@code client_secret=null} sent to the OP.
+ */
+public class AuthorizeResourceClientSecretResolutionTest {
+
+  @Test
+  public void testResolvedSecretIsReturned() {
+    final String secret = AuthorizeResource.requireResolvedAliasSecret("op.secret.alias", "s3cr3t".toCharArray());
+    assertEquals("A resolved alias must yield its secret value.", "s3cr3t", secret);
+  }
+
+  @Test
+  public void testUnresolvableAliasFailsClosed() {
+    try {
+      AuthorizeResource.requireResolvedAliasSecret("op.secret.alias", null);
+      fail("A configured-but-unresolvable alias must fail closed, not return null.");
+    } catch (ClientSecretResolutionException e) {
+      assertTrue("The error should name the offending alias.", e.getMessage().contains("op.secret.alias"));
+    }
+  }
+
+  @Test
+  public void testEmptyResolvedSecretFailsClosed() {
+    try {
+      AuthorizeResource.requireResolvedAliasSecret("op.secret.alias", new char[0]);
+      fail("An alias resolving to an empty secret must fail closed.");
+    } catch (ClientSecretResolutionException e) {
+      // expected
+    }
+  }
+}


(knox) 19/37: KNOX-3414: add opt-in Keycloak federation E2E test and activate federated-identity persistence

Posted by sm...@apache.org.
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 9a1d04a2b54ba17644dad2001a0b59e32ea75bb2
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 19:09:21 2026 +0200

    KNOX-3414: add opt-in Keycloak federation E2E test and activate federated-identity persistence
    
    Adds an opt-in end-to-end test that stands up a real Keycloak as the external
    OpenID Provider and drives the full broker flow through Knox (register client,
    /authorize -> SSOCookie redirect -> Keycloak login -> callback -> token exchange).
    It is layered in via docker-compose.knoxidf-federation.yml and is NOT part of the
    default test run (the base compose ignores test_knoxidf_federation.py).
    
    Surfacing that path exposed a production bug baked into the OIDC-provider squash:
    federated-identity persistence never activated. The service factories decided
    whether to use the JDBC-backed store by calling TopologyService.getTopologies()
    at gateway-service-init time, but topologies are not loaded yet at that point, so
    the no-op EmptyFederatedIdentityService was chosen in every deployment and the
    token exchange failed with "Federated identity not found".
    
    Fixes:
    - Move isKnoxIdfEnabledInAnyTopology into AbstractServiceFactory and add a
      topology-directory disk-scan fallback for when getTopologies() is still empty at
      init time; match both KNOXIDF and KNOXIDF_ADMIN roles. TrustedOidcIssuerServiceFactory
      now delegates to the shared helper.
    - Add a self-provisioning DerbyDBFederatedIdentityService (mirrors
      DerbyDBTokenStateService) and 3-way backend auto-selection: explicit impl wins;
      otherwise an operator-configured external DB -> JDBC, else the embedded Derby
      default so federation works out of the box with no extra infrastructure.
    - SQL DDL: the create-table runner executes a single statement per file, so fold
      the federated-identity UNIQUE constraint inline and drop trailing semicolons.
    - Validate federated OP id_tokens with a JWS type verifier that accepts typ=JWT
      and an absent typ (Keycloak and most OPs set typ=JWT; the shared token authority
      rejects any typ'd token when no verifier is supplied).
    - getPrefix() now returns "knoxidf." (trailing dot) so knoxidf.knox.token.* topology
      params map onto the base KNOXTOKEN params; add the same override to AuthorizeResource
      so its callback-minted tokens honor the per-user limit/ttl configured on the topology.
    
    Covered by FederatedIdentityServiceFactoryTest, DerbyDBFederatedIdentityServiceTest,
    and the 3 federation E2E tests (all green); default KnoxIDF E2E and the knoxidf/
    knoxtoken JUnit suites remain green.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .github/workflows/build/Dockerfile                 |   2 +
 .../build/conf/topologies/knoxidf-sso.xml          | 120 +++++++++++
 .../workflows/build/conf/topologies/knoxsso.xml    | 105 +++++++++
 .../compose/docker-compose.knoxidf-federation.yml  |  73 +++++++
 .github/workflows/compose/docker-compose.yml       |   2 +-
 .github/workflows/compose/keycloak/realm.json      |  49 +++++
 .github/workflows/tests/test_knoxidf_federation.py | 234 +++++++++++++++++++++
 .gitignore                                         |   1 +
 .../org/apache/knox/gateway/GatewayMessages.java   |   6 +
 .../services/factory/AbstractServiceFactory.java   |  79 +++++++
 .../factory/FederatedIdentityServiceFactory.java   |  83 +++++---
 .../factory/TrustedOidcIssuerServiceFactory.java   |  26 +--
 .../DerbyDBFederatedIdentityService.java           | 101 +++++++++
 ...eateKnoxIDFFederatedIdentityAttributesTable.sql |   2 +-
 ...noxIDFFederatedIdentityAttributesTableDerby.sql |   2 +-
 ...oxIDFFederatedIdentityAttributesTableOracle.sql |   2 +-
 .../createKnoxIDFFederatedIdentityTable.sql        |   7 +-
 .../createKnoxIDFFederatedIdentityTableDerby.sql   |   7 +-
 .../createKnoxIDFFederatedIdentityTableOracle.sql  |   7 +-
 .../FederatedIdentityServiceFactoryTest.java       | 211 +++++++++++++++++++
 .../DerbyDBFederatedIdentityServiceTest.java       | 108 ++++++++++
 .../gateway/service/knoxidf/AuthorizeResource.java |  17 +-
 .../gateway/service/knoxidf/TokenResource.java     |   2 +-
 23 files changed, 1175 insertions(+), 71 deletions(-)

diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile
index 5c0407661..3934c5d11 100644
--- a/.github/workflows/build/Dockerfile
+++ b/.github/workflows/build/Dockerfile
@@ -46,6 +46,8 @@ ADD .github/workflows/build/conf/topologies/remoteauth.xml /knox-runtime/conf/to
 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
+ADD .github/workflows/build/conf/topologies/knoxsso.xml /knox-runtime/conf/topologies/knoxsso.xml
+ADD .github/workflows/build/conf/topologies/knoxidf-sso.xml /knox-runtime/conf/topologies/knoxidf-sso.xml
 
 RUN chown -R gateway /knox-runtime/
 
diff --git a/.github/workflows/build/conf/topologies/knoxidf-sso.xml b/.github/workflows/build/conf/topologies/knoxidf-sso.xml
new file mode 100644
index 000000000..596db8284
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxidf-sso.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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.
+-->
+<!--
+  CI-only KnoxIDF topology exercised by the opt-in federation E2E test
+  (test_knoxidf_federation.py). Unlike knoxidf-ldap.xml (which fronts /authorize with
+  LDAP Basic auth), this topology fronts /authorize with an SSOCookieProvider. An
+  unauthenticated /authorize is redirected to the knoxsso login front-end; because a
+  federated OP (Keycloak) is configured below, the SSOCookieFederationFilter records the
+  request and offers the OP as a login option, brokering the user out to Keycloak and back
+  through /authorize/callback.
+
+  The federated.op.* settings are KNOXIDF service params so they are exposed as
+  ServletContext init-params, which is where both AuthorizeResource and the
+  SSOCookieFederationFilter read them (same webapp, shared ServletContext).
+-->
+<topology>
+    <gateway>
+        <provider>
+            <role>federation</role>
+            <name>SSOCookieProvider</name>
+            <enabled>true</enabled>
+            <param>
+                <name>sso.authentication.provider.url</name>
+                <value>https://knox:8443/gateway/knoxsso/api/v1/websso</value>
+            </param>
+            <param>
+                <name>sso.unauthenticated.path.list</name>
+                <value>/knoxidf/api/v1/authorize/callback;/knoxidf/api/v1/jwks;/knoxidf/api/v1/.well-known/openid-configuration;/knoxidf/api/v1/client/register</value>
+            </param>
+        </provider>
+        <provider>
+            <role>identity-assertion</role>
+            <name>Default</name>
+            <enabled>true</enabled>
+        </provider>
+    </gateway>
+
+    <service>
+        <role>KNOXIDF</role>
+        <param>
+            <name>knoxidf.knox.token.ttl</name>
+            <value>60000</value>
+        </param>
+        <param>
+            <name>knoxidf.knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+        <param>
+            <name>knoxidf.client.registration.anonymous.allowed</name>
+            <value>true</value>
+        </param>
+        <param>
+            <name>knoxidf.auto.consent.enabled</name>
+            <value>true</value>
+        </param>
+        <param>
+            <name>token.exchange.topology.name</name>
+            <value>knoxidf-token</value>
+        </param>
+        <param>
+            <name>federated.op.names</name>
+            <value>keycloak</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.enabled</name>
+            <value>true</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.clientId</name>
+            <value>knox-client</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.clientSecret</name>
+            <value>knox-client-secret</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.authorize.endpoint</name>
+            <value>http://keycloak:8080/realms/knox/protocol/openid-connect/auth</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.authorize.callback</name>
+            <value>https://knox:8443/gateway/knoxidf-sso/knoxidf/api/v1/authorize/callback</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.token.endpoint</name>
+            <value>http://keycloak:8080/realms/knox/protocol/openid-connect/token</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.jwks.endpoint</name>
+            <value>http://keycloak:8080/realms/knox/protocol/openid-connect/certs</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.issuer</name>
+            <value>http://keycloak:8080/realms/knox</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.userinfo.endpoint</name>
+            <value>http://keycloak:8080/realms/knox/protocol/openid-connect/userinfo</value>
+        </param>
+        <param>
+            <name>federated.op.keycloak.signature.algorithm</name>
+            <value>RS256</value>
+        </param>
+    </service>
+</topology>
diff --git a/.github/workflows/build/conf/topologies/knoxsso.xml b/.github/workflows/build/conf/topologies/knoxsso.xml
new file mode 100644
index 000000000..39c9cea6f
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxsso.xml
@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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.
+-->
+<!--
+  CI-only KnoxSSO topology used by the opt-in KnoxIDF federation E2E test
+  (test_knoxidf_federation.py). It is the authentication front-end that the
+  knoxidf-sso topology's SSOCookieProvider redirects unauthenticated /authorize
+  requests to. The /api/v1/websso/federated/op endpoint (WebSSOResource.federatedOpLogin)
+  must be reachable anonymously so the browser can be bounced on to the external OP
+  (Keycloak) without first logging in locally.
+
+  This overwrites the knoxsso.xml shipped in the release tarball (the ADD in the
+  Dockerfile wins) so the LDAP endpoint matches the CI LDAP (ldaps://localhost:33390)
+  and the redirect whitelist accepts the dot-less "knox" compose hostname.
+-->
+<topology>
+    <gateway>
+        <provider>
+            <role>authentication</role>
+            <name>ShiroProvider</name>
+            <enabled>true</enabled>
+            <param>
+                <name>sessionTimeout</name>
+                <value>30</value>
+            </param>
+            <param>
+                <name>redirectToUrl</name>
+                <value>/${GATEWAY_PATH}/knoxsso/knoxauth/login.html</value>
+            </param>
+            <param>
+                <name>restrictedCookies</name>
+                <value>rememberme,WWW-Authenticate</value>
+            </param>
+            <param>
+                <name>main.ldapRealm</name>
+                <value>org.apache.knox.gateway.shirorealm.KnoxLdapRealm</value>
+            </param>
+            <param>
+                <name>main.ldapRealm.userDnTemplate</name>
+                <value>uid={0},ou=people,dc=hadoop,dc=apache,dc=org</value>
+            </param>
+            <param>
+                <name>main.ldapRealm.contextFactory.url</name>
+                <value>ldaps://localhost:33390</value>
+            </param>
+            <param>
+                <name>main.ldapRealm.authenticationCachingEnabled</name>
+                <value>false</value>
+            </param>
+            <param>
+                <name>main.ldapRealm.contextFactory.authenticationMechanism</name>
+                <value>simple</value>
+            </param>
+            <param>
+                <name>urls./api/v1/websso/federated/op</name>
+                <value>anon</value>
+            </param>
+            <param>
+                <name>urls./**</name>
+                <value>authcBasic</value>
+            </param>
+        </provider>
+
+        <provider>
+            <role>identity-assertion</role>
+            <name>Default</name>
+            <enabled>true</enabled>
+        </provider>
+    </gateway>
+
+    <application>
+        <name>knoxauth</name>
+    </application>
+
+    <service>
+        <role>KNOXSSO</role>
+        <param>
+            <name>knoxsso.token.ttl</name>
+            <value>86400000</value>
+        </param>
+        <param>
+            <name>knox.token.exp.server-managed</name>
+            <value>false</value>
+        </param>
+        <param>
+            <name>knoxsso.redirect.whitelist.regex</name>
+            <value>^https?://knox:[0-9]+/gateway/.*$</value>
+        </param>
+    </service>
+
+</topology>
diff --git a/.github/workflows/compose/docker-compose.knoxidf-federation.yml b/.github/workflows/compose/docker-compose.knoxidf-federation.yml
new file mode 100644
index 000000000..0b2b4676d
--- /dev/null
+++ b/.github/workflows/compose/docker-compose.knoxidf-federation.yml
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with this
+# work for additional information regarding copyright ownership. The ASF
+# licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# <p>
+# http://www.apache.org/licenses/LICENSE-2.0
+# <p>
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Opt-in override that layers a real Keycloak (external OpenID Provider) onto the base
+# compose stack and runs ONLY the KnoxIDF federation E2E test. It is intentionally not part
+# of the default test run (which ignores test_knoxidf_federation.py) because it pulls the
+# Keycloak image and adds minutes of startup.
+#
+# Usage:
+#   cd .github/workflows/compose
+#   IMAGE_TAG=knoxidf docker compose \
+#     -f docker-compose.yml -f docker-compose.knoxidf-federation.yml \
+#     up --build --abort-on-container-exit --exit-code-from tests
+
+services:
+  keycloak:
+    image: quay.io/keycloak/keycloak:26.2.4
+    command:
+      - start-dev
+      - --import-realm
+    environment:
+      - KC_BOOTSTRAP_ADMIN_USERNAME=admin
+      - KC_BOOTSTRAP_ADMIN_PASSWORD=admin
+      - KC_HOSTNAME_STRICT=false
+      - KC_HTTP_ENABLED=true
+      - KC_HEALTH_ENABLED=true
+    volumes:
+      - ./keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
+    healthcheck:
+      # Keycloak 25+ serves health on the management port (9000). The image has no curl, so
+      # use bash's /dev/tcp per Keycloak's own recommendation.
+      test:
+        - CMD-SHELL
+        - >
+          exec 3<>/dev/tcp/localhost/9000;
+          echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3;
+          cat <&3 | grep -q 'UP'
+      interval: 10s
+      timeout: 5s
+      retries: 30
+      start_period: 60s
+
+  knox:
+    depends_on:
+      keycloak:
+        condition: service_healthy
+
+  tests:
+    environment:
+      - KNOX_GATEWAY_URL=https://knox:8443/
+      - KEYCLOAK_URL=http://keycloak:8080
+    command: >
+      bash -c "pip install -r requirements.txt
+      && echo 'Waiting for knox...'
+      && sleep 30
+      && pytest test_knoxidf_federation.py --junitxml=test-results-federation.xml"
+    depends_on:
+      knox:
+        condition: service_started
+      keycloak:
+        condition: service_healthy
diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml
index 727bca263..3757f8837 100644
--- a/.github/workflows/compose/docker-compose.yml
+++ b/.github/workflows/compose/docker-compose.yml
@@ -114,7 +114,7 @@ services:
       && pylint *.py
       && echo 'Waiting for knox...'
       && sleep 30
-      && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --junitxml=test-results.xml"
+      && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --ignore=test_knoxidf_federation.py --junitxml=test-results.xml"
     depends_on:
       - knox
 
diff --git a/.github/workflows/compose/keycloak/realm.json b/.github/workflows/compose/keycloak/realm.json
new file mode 100644
index 000000000..bf6f57b87
--- /dev/null
+++ b/.github/workflows/compose/keycloak/realm.json
@@ -0,0 +1,49 @@
+{
+  "realm": "knox",
+  "enabled": true,
+  "sslRequired": "none",
+  "registrationAllowed": false,
+  "loginWithEmailAllowed": true,
+  "clients": [
+    {
+      "clientId": "knox-client",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": false,
+      "secret": "knox-client-secret",
+      "standardFlowEnabled": true,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "consentRequired": false,
+      "fullScopeAllowed": true,
+      "redirectUris": [
+        "https://knox:8443/gateway/knoxidf-sso/knoxidf/api/v1/authorize/callback"
+      ],
+      "webOrigins": [
+        "+"
+      ],
+      "defaultClientScopes": [
+        "profile",
+        "email"
+      ],
+      "optionalClientScopes": []
+    }
+  ],
+  "users": [
+    {
+      "username": "alice",
+      "enabled": true,
+      "emailVerified": true,
+      "email": "alice@example.com",
+      "firstName": "Alice",
+      "lastName": "Example",
+      "credentials": [
+        {
+          "type": "password",
+          "value": "alice-password",
+          "temporary": false
+        }
+      ]
+    }
+  ]
+}
diff --git a/.github/workflows/tests/test_knoxidf_federation.py b/.github/workflows/tests/test_knoxidf_federation.py
new file mode 100644
index 000000000..2806e0856
--- /dev/null
+++ b/.github/workflows/tests/test_knoxidf_federation.py
@@ -0,0 +1,234 @@
+# 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.
+
+"""End-to-end test of the KnoxIDF federation path: Knox brokering login to Keycloak.
+
+This is opt-in and NOT part of the default test run (the base docker-compose ignores it).
+It runs only under the docker-compose.knoxidf-federation.yml override, which stands up a
+real Keycloak as the external OpenID Provider. See that override file for how to run it.
+
+Flow exercised (all hops carried on one requests.Session so cookies persist):
+  1. register an OIDC client on the knoxidf-sso topology (anonymous registration),
+  2. GET /authorize -> SSOCookieProvider redirects to knoxsso with a federated-OP session id,
+  3. GET /api/v1/websso/federated/op -> redirect to Keycloak's authorize endpoint,
+  4. submit alice's credentials to Keycloak's login form -> redirect to the Knox callback,
+  5. Knox validates the OP id_token, resumes /authorize, and redirects with a Knox code,
+  6. exchange the Knox code at the token endpoint for Knox access/id/refresh tokens.
+"""
+
+import os
+import unittest
+import uuid
+from html.parser import HTMLParser
+from urllib.parse import urljoin, urlparse, parse_qs
+
+import requests
+
+from common_utils import gateway_base_url, get_token_claim, KNOX_REQUEST_TIMEOUT
+
+# Client-side (relying-party) details. The redirect_uri is a loopback http URL, which the
+# registration redirect-URI policy permits (RFC 8252). The client state is echoed back to
+# the client redirect and is distinct from the federated-OP login session id.
+CLIENT_REDIRECT_URI = "http://localhost/callback"
+CLIENT_STATE = "knox_fed_client_state"
+
+# The seeded Keycloak realm user (see compose/keycloak/realm.json).
+KC_USERNAME = "alice"
+KC_PASSWORD = "alice-password"
+KC_EMAIL = "alice@example.com"
+
+
+class _LoginFormParser(HTMLParser):
+    """Scrapes the first HTML <form>: its action plus every input's name/value."""
+
+    def __init__(self):
+        super().__init__()
+        self.action = None
+        self.inputs = {}
+        self._in_form = False
+
+    def handle_starttag(self, tag, attrs):
+        """Record the form action and any inputs inside the first form."""
+        attributes = dict(attrs)
+        if tag == "form" and self.action is None:
+            self.action = attributes.get("action")
+            self._in_form = True
+        elif tag == "input" and self._in_form:
+            name = attributes.get("name")
+            if name:
+                self.inputs[name] = attributes.get("value", "")
+
+    def handle_endtag(self, tag):
+        """Stop collecting inputs once the first form closes."""
+        if tag == "form":
+            self._in_form = False
+
+
+class TestKnoxIDFFederation(unittest.TestCase):
+    """Federation broker tests: Knox delegating authentication to Keycloak."""
+
+    def setUp(self):
+        self.base_url = gateway_base_url()
+        self.knoxidf_sso_url = f"{self.base_url}gateway/knoxidf-sso/"
+        self.knoxsso_url = f"{self.base_url}gateway/knoxsso/"
+        self.knoxidf_token_url = f"{self.base_url}gateway/knoxidf-token/"
+        self.keycloak_url = os.environ.get("KEYCLOAK_URL", "http://keycloak:8080")
+
+    @staticmethod
+    def _new_session():
+        """A cookie-carrying session that tolerates Knox's self-signed TLS."""
+        session = requests.Session()
+        session.verify = False
+        return session
+
+    def _register_client(self, session):
+        """Register a confidential client and return (client_id, client_secret)."""
+        url = f"{self.knoxidf_sso_url}knoxidf/api/v1/client/register"
+        payload = {
+            "redirect_uris": CLIENT_REDIRECT_URI,
+            "allowed_scopes": "openid,profile,email,offline_access",
+        }
+        response = session.post(url, data=payload, timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertEqual(response.status_code, 200, response.text)
+        body = response.json()
+        return body["client_id"], body["client_secret"]
+
+    def _start_authorize(self, session, client_id):
+        """Kick off /authorize; return the federated-OP login session id from the redirect."""
+        url = f"{self.knoxidf_sso_url}knoxidf/api/v1/authorize"
+        params = {
+            "response_type": "code",
+            "client_id": client_id,
+            "redirect_uri": CLIENT_REDIRECT_URI,
+            "scope": "openid offline_access",
+            "state": CLIENT_STATE,
+        }
+        response = session.get(url, params=params, allow_redirects=False,
+                               timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertIn(response.status_code, (302, 303, 307), response.text)
+        location = response.headers.get("Location")
+        self.assertIsNotNone(location, "SSOCookieProvider did not issue a login redirect")
+        query = parse_qs(urlparse(location).query)
+        self.assertIn("federatedOpLoginSession", query, location)
+        self.assertIn("keycloak", query.get("federatedOpNames", [""])[0])
+        return query["federatedOpLoginSession"][0]
+
+    def _kickoff_federated_op(self, session, login_session_id):
+        """Select the Keycloak OP; return the Keycloak authorize URL Knox redirects to."""
+        url = f"{self.knoxsso_url}api/v1/websso/federated/op"
+        params = {"fedOpSid": login_session_id, "fedOpName": "keycloak"}
+        response = session.get(url, params=params, allow_redirects=False,
+                               timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertIn(response.status_code, (302, 303, 307), response.text)
+        location = response.headers.get("Location")
+        self.assertIsNotNone(location)
+        self.assertTrue(location.startswith(self.keycloak_url), location)
+        return location
+
+    def _keycloak_login(self, session, keycloak_authorize_url):
+        """Submit alice's credentials to Keycloak; return the Knox callback URL it redirects to."""
+        page = session.get(keycloak_authorize_url, allow_redirects=True,
+                           timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertEqual(page.status_code, 200, "Keycloak did not render a login page")
+        parser = _LoginFormParser()
+        parser.feed(page.text)
+        self.assertIsNotNone(parser.action, "No login form found on the Keycloak page")
+        form = dict(parser.inputs)
+        form["username"] = KC_USERNAME
+        form["password"] = KC_PASSWORD
+        action = urljoin(page.url, parser.action)
+        submitted = session.post(action, data=form, allow_redirects=False,
+                                 timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertIn(submitted.status_code, (302, 303), submitted.text)
+        location = submitted.headers.get("Location")
+        self.assertIsNotNone(location, "Keycloak did not redirect back after login")
+        self.assertTrue(location.startswith(self.base_url), location)
+        return location
+
+    def _knox_callback(self, session, callback_url):
+        """Follow the OP callback into Knox; return the Knox authorization code."""
+        response = session.get(callback_url, allow_redirects=False,
+                               timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertIn(response.status_code, (302, 303), response.text)
+        location = response.headers.get("Location")
+        self.assertIsNotNone(location)
+        self.assertTrue(location.startswith(CLIENT_REDIRECT_URI), location)
+        query = parse_qs(urlparse(location).query)
+        self.assertIn("code", query, location)
+        self.assertEqual(query.get("state", [""])[0], CLIENT_STATE)
+        return query["code"][0]
+
+    def _exchange_code(self, session, client_id, client_secret, code):
+        """Exchange a Knox authorization code for the Knox token set."""
+        url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
+        payload = {
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": CLIENT_REDIRECT_URI,
+            "client_id": client_id,
+            "client_secret": client_secret,
+        }
+        response = session.post(url, data=payload, timeout=KNOX_REQUEST_TIMEOUT)
+        self.assertEqual(response.status_code, 200, response.text)
+        return response.json()
+
+    def _run_full_flow(self):
+        """Drive the whole broker flow on a fresh session; return the Knox token set."""
+        session = self._new_session()
+        client_id, client_secret = self._register_client(session)
+        login_session_id = self._start_authorize(session, client_id)
+        keycloak_authorize_url = self._kickoff_federated_op(session, login_session_id)
+        callback_url = self._keycloak_login(session, keycloak_authorize_url)
+        code = self._knox_callback(session, callback_url)
+        return self._exchange_code(session, client_id, client_secret, code)
+
+    def test_federation_returns_all_tokens(self):
+        """The brokered flow yields access, id, and refresh tokens with a usable token type."""
+        tokens = self._run_full_flow()
+        self.assertIn("access_token", tokens)
+        self.assertIn("id_token", tokens)
+        self.assertIn("refresh_token", tokens)
+        self.assertEqual(tokens.get("token_type", "").lower(), "bearer")
+        self.assertGreater(int(tokens.get("expires_in", 0)), 0)
+
+    def test_id_token_carries_federated_claims(self):
+        """The Knox id_token records the OP provenance and a standard email claim."""
+        tokens = self._run_full_flow()
+        id_token = tokens["id_token"]
+
+        self.assertEqual(get_token_claim(id_token, "federated_idp"), "KEYCLOAK")
+        self.assertEqual(
+            get_token_claim(id_token, "federated_iss"),
+            f"{self.keycloak_url}/realms/knox",
+        )
+        self.assertTrue(get_token_claim(id_token, "federated_sub"))
+        self.assertEqual(get_token_claim(id_token, "email"), KC_EMAIL)
+
+        # The Knox subject is a deterministic UUIDv5 derived from the OP issuer+subject.
+        subject = get_token_claim(id_token, "sub")
+        self.assertEqual(uuid.UUID(subject).version, 5)
+
+    def test_same_keycloak_user_maps_to_stable_knox_subject(self):
+        """Two independent logins by the same OP user resolve to one persisted Knox subject."""
+        first = self._run_full_flow()
+        second = self._run_full_flow()
+        first_sub = get_token_claim(first["id_token"], "sub")
+        second_sub = get_token_claim(second["id_token"], "sub")
+        self.assertTrue(first_sub)
+        self.assertEqual(first_sub, second_sub)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/.gitignore b/.gitignore
index 17236cdcd..f28ecbe32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@ velocity.log
 # Workflow rules
 .github/workflows/compose/logs/*
 .github/workflows/tests/test-results.xml
+.github/workflows/tests/test-results-federation.xml
 
 
 # other IDEs and editors
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
index d4639b011..a71fbf5b3 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
@@ -738,6 +738,12 @@ public interface GatewayMessages {
   @Message(level = MessageLevel.ERROR, text = "Error while initializing {0}: {1}")
   void errorInitializingService(String implementation, String error, @StackTrace(level = MessageLevel.DEBUG) Exception e);
 
+  @Message(level = MessageLevel.DEBUG, text = "Failed to list topology directory {0} while detecting KnoxIDF: {1}")
+  void failedToListTopologyDirForKnoxIdfDetection(String topologyDir, String error, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.DEBUG, text = "Failed to read topology file {0} while detecting KnoxIDF: {1}")
+  void failedToReadTopologyFileForKnoxIdfDetection(String topologyFile, String error, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
   @Message(level = MessageLevel.ERROR,
           text = "Unable to complete service discovery for cluster {0} topology = {1}.")
   void failedToDiscoverClusterServices(String clusterName, String topologyName,
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java
index 4b60fa434..2049c72bb 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java
@@ -17,9 +17,16 @@
  */
 package org.apache.knox.gateway.services.factory;
 
+import java.io.IOException;
 import java.lang.reflect.InvocationTargetException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Collection;
+import java.util.Locale;
 import java.util.Map;
+import java.util.stream.Stream;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.knox.gateway.GatewayMessages;
@@ -33,6 +40,8 @@ import org.apache.knox.gateway.services.ServiceType;
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.KeystoreService;
 import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
 
 public abstract class AbstractServiceFactory implements ServiceFactory {
 
@@ -40,6 +49,10 @@ public abstract class AbstractServiceFactory implements ServiceFactory {
   private static final String IMPLEMENTATION_PARAM_NAME = "impl";
   private static final String EMPTY_DEFAULT_IMPLEMENTATION = "";
 
+  /** Topology service roles that enable the KnoxIDF-backed service implementations. */
+  private static final String KNOXIDF_ROLE = "KNOXIDF";
+  private static final String KNOXIDF_ADMIN_ROLE = "KNOXIDF_ADMIN";
+
   @Override
   public Service create(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map<String, String> options) throws ServiceLifecycleException {
     return create(gatewayServices, serviceType, gatewayConfig, options, getImplementation(gatewayConfig));
@@ -105,6 +118,72 @@ public abstract class AbstractServiceFactory implements ServiceFactory {
     LOG.usingServiceImplementation(isEmptyDefaultImplementation(implementation) ? "default" : implementation, serviceType.getServiceTypeName());
   }
 
+  /**
+   * Returns {@code true} if any topology enables KnoxIDF (a service with role {@code KNOXIDF} or
+   * {@code KNOXIDF_ADMIN}).
+   * <p>
+   * Service factories run during {@code DefaultGatewayServices.init}, before the topology monitor
+   * has loaded any topologies, so {@link TopologyService#getTopologies()} is typically empty at
+   * this point. To detect KnoxIDF anyway we fall back to scanning the on-disk topology directory
+   * for the enabling role. The in-memory check is kept first so a caller that runs after topologies
+   * are loaded still works. Known limitation: descriptor-generated {@code .topology} files that are
+   * not yet materialised on disk at init time are not seen (still strictly better than relying on
+   * the empty in-memory map alone).
+   */
+  protected boolean isKnoxIdfEnabledInAnyTopology(GatewayServices gatewayServices, GatewayConfig gatewayConfig) {
+    final TopologyService topologyService = gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE);
+    if (topologyService != null) {
+      for (Topology topology : topologyService.getTopologies()) {
+        if (topology.getServices().stream().anyMatch(service -> isKnoxIdfRole(service.getRole()))) {
+          return true;
+        }
+      }
+    }
+    return isKnoxIdfEnabledOnDisk(gatewayConfig);
+  }
+
+  private static boolean isKnoxIdfRole(String role) {
+    return KNOXIDF_ROLE.equals(role) || KNOXIDF_ADMIN_ROLE.equals(role);
+  }
+
+  private static boolean isKnoxIdfEnabledOnDisk(GatewayConfig gatewayConfig) {
+    if (gatewayConfig == null) {
+      return false;
+    }
+    final String topologyDir = gatewayConfig.getGatewayTopologyDir();
+    if (StringUtils.isBlank(topologyDir)) {
+      return false;
+    }
+    final Path dir = Paths.get(topologyDir);
+    if (!Files.isDirectory(dir)) {
+      return false;
+    }
+    try (Stream<Path> files = Files.list(dir)) {
+      return files.filter(AbstractServiceFactory::isTopologyFile).anyMatch(AbstractServiceFactory::topologyFileEnablesKnoxIdf);
+    } catch (IOException e) {
+      LOG.failedToListTopologyDirForKnoxIdfDetection(topologyDir, e.getMessage(), e);
+      return false;
+    }
+  }
+
+  private static boolean isTopologyFile(Path path) {
+    if (!Files.isRegularFile(path)) {
+      return false;
+    }
+    final String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
+    return name.endsWith(".xml") || name.endsWith(".topology");
+  }
+
+  private static boolean topologyFileEnablesKnoxIdf(Path path) {
+    try {
+      final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
+      return content.contains("<role>" + KNOXIDF_ROLE + "</role>") || content.contains("<role>" + KNOXIDF_ADMIN_ROLE + "</role>");
+    } catch (IOException e) {
+      LOG.failedToReadTopologyFileForKnoxIdfDetection(path.toString(), e.getMessage(), e);
+      return false;
+    }
+  }
+
   // abstract methods
 
   protected abstract Service createService(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map<String, String> options,
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
index 7ffe009fa..fbfeaa6d0 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
@@ -18,16 +18,16 @@ package org.apache.knox.gateway.services.factory;
 
 import org.apache.knox.gateway.GatewayMessages;
 import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.database.DatabaseType;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.services.GatewayServices;
 import org.apache.knox.gateway.services.Service;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.federation.DerbyDBFederatedIdentityService;
 import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentityService;
 import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
 import org.apache.knox.gateway.services.knoxidf.federation.JdbcFederatedIdentityService;
-import org.apache.knox.gateway.services.topology.TopologyService;
-import org.apache.knox.gateway.topology.Topology;
 
 import java.util.Collection;
 import java.util.List;
@@ -43,46 +43,71 @@ public class FederatedIdentityServiceFactory extends AbstractServiceFactory {
             throws ServiceLifecycleException {
 
         String implementationToUse = implementation;
-        // If implementation is empty, check if we should auto-enable JdbcFederatedIdentityService
-        if (isEmptyDefaultImplementation(implementationToUse)) {
-            if (isKnoxIdfEnabledInAnyTopology(gatewayServices)) {
-                implementationToUse = JdbcFederatedIdentityService.class.getName();
-            }
+        // No explicit impl configured: auto-select a persistence backend when KnoxIDF is deployed.
+        // Otherwise honor the configured impl (very likely a prod JDBC store).
+        if (isEmptyDefaultImplementation(implementationToUse) && isKnoxIdfEnabledInAnyTopology(gatewayServices, gatewayConfig)) {
+            implementationToUse = chooseAutoImplementation(gatewayConfig);
         }
 
         FederatedIdentityService service = null;
         if (shouldCreateService(implementationToUse)) {
             if (matchesImplementation(implementationToUse, EmptyFederatedIdentityService.class, true)) {
                 service = new EmptyFederatedIdentityService();
+            } else if (matchesImplementation(implementationToUse, DerbyDBFederatedIdentityService.class)) {
+                service = createDerbyService(gatewayServices, gatewayConfig, options);
             } else if (matchesImplementation(implementationToUse, JdbcFederatedIdentityService.class)) {
-                try {
-                    try {
-                        service = new JdbcFederatedIdentityService();
-                        ((JdbcFederatedIdentityService) service).setAliasService(getAliasService(gatewayServices));
-                        service.init(gatewayConfig, options);
-                    } catch (ServiceLifecycleException e) {
-                        LOG.errorInitializingService(implementationToUse, e.getMessage(), e);
-                        service =  new EmptyFederatedIdentityService();
-                    }
-                } catch (Exception e) {
-                    throw new ServiceLifecycleException("Error while creating Federated Identity Service: " + e, e);
-                }
+                service = createJdbcService(gatewayServices, gatewayConfig, options);
             }
             logServiceUsage(service.getClass().getName(), serviceType);
         }
         return service;
     }
 
-    private boolean isKnoxIdfEnabledInAnyTopology(GatewayServices gatewayServices) {
-        final TopologyService topologyService = gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE);
-        if (topologyService != null) {
-            for (Topology topology : topologyService.getTopologies()) {
-                if (topology.getServices().stream().anyMatch(service -> "KNOXIDF".equals(service.getRole()))) {
-                    return true;
-                }
-            }
+    /**
+     * Chooses the auto-enabled implementation when KnoxIDF is deployed with no explicit impl: an
+     * operator-configured external database wins (very likely a prod JDBC store), otherwise a
+     * self-provisioning embedded Derby store (the {@code none}/{@code derbydb} default) so
+     * federation works out of the box without any extra infrastructure.
+     */
+    String chooseAutoImplementation(GatewayConfig gatewayConfig) {
+        return isExternalDatabaseConfigured(gatewayConfig)
+                ? JdbcFederatedIdentityService.class.getName()
+                : DerbyDBFederatedIdentityService.class.getName();
+    }
+
+    private boolean isExternalDatabaseConfigured(GatewayConfig gatewayConfig) {
+        final String databaseType = gatewayConfig.getDatabaseType();
+        try {
+            return DatabaseType.fromString(databaseType) != DatabaseType.DERBY;
+        } catch (IllegalArgumentException e) {
+            // "none" (the default) or any unrecognized value: no real external DB -> use Derby.
+            return false;
+        }
+    }
+
+    private FederatedIdentityService createDerbyService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map<String, String> options) {
+        try {
+            final DerbyDBFederatedIdentityService derbyService = new DerbyDBFederatedIdentityService();
+            derbyService.setAliasService(getAliasService(gatewayServices));
+            derbyService.setMasterService(getMasterService(gatewayServices));
+            derbyService.init(gatewayConfig, options);
+            return derbyService;
+        } catch (ServiceLifecycleException e) {
+            LOG.errorInitializingService(DerbyDBFederatedIdentityService.class.getName(), e.getMessage(), e);
+            return new EmptyFederatedIdentityService();
+        }
+    }
+
+    private FederatedIdentityService createJdbcService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map<String, String> options) {
+        try {
+            final JdbcFederatedIdentityService jdbcService = new JdbcFederatedIdentityService();
+            jdbcService.setAliasService(getAliasService(gatewayServices));
+            jdbcService.init(gatewayConfig, options);
+            return jdbcService;
+        } catch (ServiceLifecycleException e) {
+            LOG.errorInitializingService(JdbcFederatedIdentityService.class.getName(), e.getMessage(), e);
+            return new EmptyFederatedIdentityService();
         }
-        return false;
     }
 
     @Override
@@ -92,6 +117,6 @@ public class FederatedIdentityServiceFactory extends AbstractServiceFactory {
 
     @Override
     protected Collection<String> getKnownImplementations() {
-        return List.of(DEFAULT_IMPLEMENTATION, JdbcFederatedIdentityService.class.getName());
+        return List.of(DEFAULT_IMPLEMENTATION, JdbcFederatedIdentityService.class.getName(), DerbyDBFederatedIdentityService.class.getName());
     }
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
index daa8d11a4..3f520f094 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
@@ -26,8 +26,6 @@ import org.apache.knox.gateway.services.ServiceType;
 import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService;
 import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService;
 import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
-import org.apache.knox.gateway.services.topology.TopologyService;
-import org.apache.knox.gateway.topology.Topology;
 
 import java.util.Collection;
 import java.util.List;
@@ -44,10 +42,8 @@ public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
       throws ServiceLifecycleException {
 
     String implementationToUse = implementation;
-    if (isEmptyDefaultImplementation(implementationToUse)) {
-      if (isKnoxIdfEnabledInAnyTopology(gatewayServices)) {
-        implementationToUse = JdbcTrustedOidcIssuerService.class.getName();
-      }
+    if (isEmptyDefaultImplementation(implementationToUse) && isKnoxIdfEnabledInAnyTopology(gatewayServices, gatewayConfig)) {
+      implementationToUse = JdbcTrustedOidcIssuerService.class.getName();
     }
 
     TrustedOidcIssuerService service = null;
@@ -75,24 +71,6 @@ public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
     return service;
   }
 
-  /**
-   * Returns true if any deployed topology contains a service with role {@code KNOXIDF}
-   * or {@code KNOXIDF_ADMIN}. The trusted issuer registry is activated by either role
-   * because the admin API ({@code KNOXIDF_ADMIN}) also needs to persist registrations.
-   */
-  private boolean isKnoxIdfEnabledInAnyTopology(GatewayServices gatewayServices) {
-    final TopologyService topologyService = gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE);
-    if (topologyService != null) {
-      for (Topology topology : topologyService.getTopologies()) {
-        if (topology.getServices().stream().anyMatch(
-            s -> "KNOXIDF".equals(s.getRole()) || "KNOXIDF_ADMIN".equals(s.getRole()))) {
-          return true;
-        }
-      }
-    }
-    return false;
-  }
-
   @Override
   protected ServiceType getServiceType() {
     return ServiceType.TRUSTED_OIDC_ISSUER_SERVICE;
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityService.java
new file mode 100644
index 000000000..aa4bec22c
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityService.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_NAME;
+import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_TYPE;
+import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME;
+import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME;
+import static org.apache.knox.gateway.database.DatabaseType.DERBY;
+import static org.apache.knox.gateway.services.security.AliasService.NO_CLUSTER_NAME;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.gateway.services.token.impl.DerbyDBTokenStateService;
+import org.apache.knox.gateway.shell.jdbc.derby.DerbyDatabase;
+
+/**
+ * A self-provisioning, embedded-Derby backed {@link FederatedIdentityService}. This is the
+ * auto-enabled default when KnoxIDF is deployed without an operator-configured external database,
+ * mirroring how {@link DerbyDBTokenStateService} is the default token-state service.
+ * <p>
+ * It reuses the single embedded Derby database that the token-state service already provisions
+ * under {@code ${securityDir}/tokens} (the {@code ;create=true} JDBC URL is idempotent, so
+ * connecting to an already-booted database simply connects), sets the shared {@link GatewayConfig}
+ * to point at it, ensures the connection user/password aliases exist, and then delegates all
+ * persistence to {@link JdbcFederatedIdentityService} (which builds the
+ * {@link FederatedIdentityDatabase} and self-creates its tables).
+ */
+public class DerbyDBFederatedIdentityService extends JdbcFederatedIdentityService {
+
+  private DerbyDatabase derbyDatabase;
+  private Path derbyDatabaseFolder;
+  private MasterService masterService;
+
+  public void setMasterService(MasterService masterService) {
+    this.masterService = masterService;
+  }
+
+  @Override
+  public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
+    try {
+      derbyDatabaseFolder = Paths.get(config.getGatewaySecurityDir(), DerbyDBTokenStateService.DB_NAME);
+      startDerby();
+      ((Configuration) config).set(GATEWAY_DATABASE_TYPE, DERBY.type());
+      ((Configuration) config).set(GATEWAY_DATABASE_NAME, derbyDatabaseFolder.toString());
+      getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_USER_ALIAS_NAME, getDatabaseUserName());
+      getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_PASSWORD_ALIAS_NAME, getDatabasePassword());
+      super.init(config, options);
+    } catch (Exception e) {
+      throw new ServiceLifecycleException("Error while initiating DerbyDBFederatedIdentityService: " + e, e);
+    }
+  }
+
+  private void startDerby() throws Exception {
+    derbyDatabase = new DerbyDatabase(derbyDatabaseFolder.toString());
+    derbyDatabase.create();
+    TimeUnit.SECONDS.sleep(1); // give a bit of time for the server to start
+  }
+
+  private String getDatabasePassword() throws Exception {
+    final char[] dbPasswordAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_PASSWORD_ALIAS_NAME);
+    return dbPasswordAliasValue != null ? new String(dbPasswordAliasValue) : new String(masterService.getMasterSecret());
+  }
+
+  private String getDatabaseUserName() throws Exception {
+    final char[] dbUserAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_USER_ALIAS_NAME);
+    return dbUserAliasValue != null ? new String(dbUserAliasValue) : DerbyDBTokenStateService.DEFAULT_TOKEN_DB_USER_NAME;
+  }
+
+  @Override
+  public void stop() throws ServiceLifecycleException {
+    try {
+      if (derbyDatabase != null) {
+        derbyDatabase.shutdown();
+      }
+    } catch (Exception e) {
+      throw new ServiceLifecycleException("Error while shutting down Derby Database", e);
+    }
+  }
+}
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql
index 239cb5df1..c74cf756e 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql
@@ -18,4 +18,4 @@ CREATE TABLE FEDERATED_IDENTITY_ATTR (
     attr_value  TEXT,
     PRIMARY KEY (identity_id, attr_key),
     FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY (id) ON DELETE CASCADE
-);
\ No newline at end of file
+)
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
index 90c70ff0f..60e1e247b 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
@@ -19,4 +19,4 @@ CREATE TABLE FEDERATED_IDENTITY_ATTR (
     attr_value  CLOB,
     PRIMARY KEY (identity_id, attr_key),
     CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE
-);
\ No newline at end of file
+)
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql
index 7ed07b1b0..9d89349aa 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql
@@ -19,4 +19,4 @@ CREATE TABLE FEDERATED_IDENTITY_ATTR (
     attr_value  CLOB,
     CONSTRAINT pk_fed_attr PRIMARY KEY (identity_id, attr_key),
     CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE
-);
\ No newline at end of file
+)
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql
index acaf1c340..dd2b163a7 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql
@@ -19,7 +19,6 @@ CREATE TABLE FEDERATED_IDENTITY (
     provider         VARCHAR(64)  NOT NULL,
     external_subject VARCHAR(255) NOT NULL,
     external_issuer  VARCHAR(255) NOT NULL,
-    created_at       TIMESTAMP    NOT NULL
-);
-
-CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
+    created_at       TIMESTAMP    NOT NULL,
+    CONSTRAINT UX_FED_IDENTITY UNIQUE (provider, external_issuer, external_subject)
+)
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
index acaf1c340..dd2b163a7 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
@@ -19,7 +19,6 @@ CREATE TABLE FEDERATED_IDENTITY (
     provider         VARCHAR(64)  NOT NULL,
     external_subject VARCHAR(255) NOT NULL,
     external_issuer  VARCHAR(255) NOT NULL,
-    created_at       TIMESTAMP    NOT NULL
-);
-
-CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
+    created_at       TIMESTAMP    NOT NULL,
+    CONSTRAINT UX_FED_IDENTITY UNIQUE (provider, external_issuer, external_subject)
+)
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql
index 0dc42c1f7..922d7b95a 100644
--- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql
@@ -19,7 +19,6 @@ CREATE TABLE FEDERATED_IDENTITY (
     provider         VARCHAR2(64) NOT NULL,
     external_subject VARCHAR2(255) NOT NULL,
     external_issuer  VARCHAR2(255) NOT NULL,
-    created_at       TIMESTAMP NOT NULL
-);
-
-CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
+    created_at       TIMESTAMP NOT NULL,
+    CONSTRAINT UX_FED_IDENTITY UNIQUE (provider, external_issuer, external_subject)
+)
\ No newline at end of file
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java
new file mode 100644
index 000000000..996aae6cb
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.services.factory;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.Service;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.federation.DerbyDBFederatedIdentityService;
+import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentityService;
+import org.apache.knox.gateway.services.knoxidf.federation.JdbcFederatedIdentityService;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+import org.apache.knox.test.TestUtils;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Test;
+
+public class FederatedIdentityServiceFactoryTest {
+
+  private final FederatedIdentityServiceFactory serviceFactory = new FederatedIdentityServiceFactory();
+  private final Map<String, String> options = new HashMap<>();
+  private File tempDir;
+  private Service createdService;
+
+  @After
+  public void tearDown() throws Exception {
+    if (createdService != null) {
+      createdService.stop();
+    }
+    if (tempDir != null) {
+      FileUtils.forceDelete(tempDir);
+    }
+  }
+
+  @Test
+  public void shouldChooseDerbyWhenNoDatabaseConfigured() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn("none").anyTimes();
+    EasyMock.replay(config);
+    assertEquals(DerbyDBFederatedIdentityService.class.getName(), serviceFactory.chooseAutoImplementation(config));
+  }
+
+  @Test
+  public void shouldChooseDerbyWhenDatabaseTypeIsDerby() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.replay(config);
+    assertEquals(DerbyDBFederatedIdentityService.class.getName(), serviceFactory.chooseAutoImplementation(config));
+  }
+
+  @Test
+  public void shouldChooseJdbcWhenExternalDatabaseConfigured() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.POSTGRESQL.type()).anyTimes();
+    EasyMock.replay(config);
+    assertEquals(JdbcFederatedIdentityService.class.getName(), serviceFactory.chooseAutoImplementation(config));
+  }
+
+  @Test
+  public void shouldDetectKnoxIdfFromInMemoryTopology() {
+    final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXIDF"));
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.replay(config);
+    assertTrue(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config));
+  }
+
+  @Test
+  public void shouldDetectKnoxIdfAdminFromInMemoryTopology() {
+    final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXIDF_ADMIN"));
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.replay(config);
+    assertTrue(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config));
+  }
+
+  @Test
+  public void shouldDetectKnoxIdfViaDiskScanWhenNoTopologiesLoadedYet() throws IOException {
+    // Mirrors the real init-time timing: the topology monitor has not loaded topologies yet, so the
+    // in-memory list is empty, but the topology XML already exists on disk.
+    tempDir = TestUtils.createTempDir(this.getClass().getName());
+    writeTopologyFile("knoxidf-sso.xml", "<topology><service><role>KNOXIDF</role></service></topology>");
+
+    final GatewayServices gatewayServices = servicesWithTopology(/* no in-memory topologies */);
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getGatewayTopologyDir()).andReturn(tempDir.getAbsolutePath()).anyTimes();
+    EasyMock.replay(config);
+
+    assertTrue(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config));
+  }
+
+  @Test
+  public void shouldNotDetectKnoxIdfWhenAbsentFromMemoryAndDisk() throws IOException {
+    tempDir = TestUtils.createTempDir(this.getClass().getName());
+    writeTopologyFile("sandbox.xml", "<topology><service><role>KNOXSSO</role></service></topology>");
+
+    final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXSSO"));
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getGatewayTopologyDir()).andReturn(tempDir.getAbsolutePath()).anyTimes();
+    EasyMock.replay(config);
+
+    assertFalse(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config));
+  }
+
+  @Test
+  public void shouldHonorExplicitEmptyImplEvenWhenKnoxIdfIsDeployed() throws Exception {
+    final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXIDF"));
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.replay(config);
+
+    createdService = serviceFactory.create(gatewayServices, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options,
+        EmptyFederatedIdentityService.class.getName());
+    assertTrue(createdService instanceof EmptyFederatedIdentityService);
+  }
+
+  @Test
+  public void shouldSelectEmptyWhenKnoxIdfNotDeployed() throws Exception {
+    final GatewayServices gatewayServices = servicesWithTopology(/* no topologies */);
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    // No topology dir and no in-memory topology -> KnoxIDF not enabled -> Empty.
+    EasyMock.replay(config);
+
+    createdService = serviceFactory.create(gatewayServices, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options, "");
+    assertTrue(createdService instanceof EmptyFederatedIdentityService);
+  }
+
+  @Test
+  public void shouldAutoSelectDerbyServiceWhenKnoxIdfDeployedWithoutExternalDatabase() throws Exception {
+    tempDir = TestUtils.createTempDir(this.getClass().getName());
+    final String masterSecret = "M4st3RSecret!";
+    final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
+    EasyMock.expect(masterService.getMasterSecret()).andReturn(masterSecret.toCharArray()).anyTimes();
+    EasyMock.replay(masterService);
+    final AliasService aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.replay(aliasService);
+
+    final GatewayServices gatewayServices = EasyMock.createNiceMock(GatewayServices.class);
+    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
+    EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.singletonList(topologyWithRole("KNOXIDF"))).anyTimes();
+    EasyMock.replay(topologyService);
+    EasyMock.expect(gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes();
+    EasyMock.expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes();
+    EasyMock.expect(gatewayServices.getService(ServiceType.MASTER_SERVICE)).andReturn(masterService).anyTimes();
+    EasyMock.replay(gatewayServices);
+
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(config.getGatewaySecurityDir()).andReturn(tempDir.getAbsolutePath()).anyTimes();
+    EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(tempDir.getAbsolutePath(), "tokens").toString()).anyTimes();
+    EasyMock.replay(config);
+
+    createdService = serviceFactory.create(gatewayServices, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options, "");
+    assertTrue("Expected a self-provisioning Derby-backed federated identity service, got "
+        + createdService.getClass().getName(), createdService instanceof DerbyDBFederatedIdentityService);
+  }
+
+  private Topology topologyWithRole(String role) {
+    final Topology topology = new Topology();
+    topology.setName("topology-" + role);
+    final org.apache.knox.gateway.topology.Service service = new org.apache.knox.gateway.topology.Service();
+    service.setRole(role);
+    topology.addService(service);
+    return topology;
+  }
+
+  private GatewayServices servicesWithTopology(Topology... topologies) {
+    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
+    EasyMock.expect(topologyService.getTopologies()).andReturn(Arrays.asList(topologies)).anyTimes();
+    EasyMock.replay(topologyService);
+    final GatewayServices gatewayServices = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes();
+    EasyMock.replay(gatewayServices);
+    return gatewayServices;
+  }
+
+  private void writeTopologyFile(String name, String content) throws IOException {
+    Files.write(Paths.get(tempDir.getAbsolutePath(), name), content.getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java
new file mode 100644
index 000000000..267dd28a1
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.test.TestUtils;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that {@link DerbyDBFederatedIdentityService} self-provisions an embedded Derby database
+ * and round-trips a federated identity through it.
+ */
+public class DerbyDBFederatedIdentityServiceTest {
+
+  private File securityDir;
+  private DerbyDBFederatedIdentityService service;
+
+  @Before
+  public void setUp() throws IOException {
+    securityDir = TestUtils.createTempDir(this.getClass().getName());
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (service != null) {
+      service.stop();
+    }
+    if (securityDir != null) {
+      FileUtils.forceDelete(securityDir);
+    }
+  }
+
+  @Test
+  public void shouldRoundTripAFederatedIdentityOnEmbeddedDerby() throws Exception {
+    final String masterSecret = "M4st3RSecret!";
+    final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
+    EasyMock.expect(masterService.getMasterSecret()).andReturn(masterSecret.toCharArray()).anyTimes();
+    EasyMock.replay(masterService);
+
+    final AliasService aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.replay(aliasService);
+
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir.getAbsolutePath()).anyTimes();
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(securityDir.getAbsolutePath(), "tokens").toString()).anyTimes();
+    EasyMock.replay(config);
+
+    service = new DerbyDBFederatedIdentityService();
+    service.setAliasService(aliasService);
+    service.setMasterService(masterService);
+    service.init(config, Collections.emptyMap());
+
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put("email", "alice@example.com");
+    final FederatedIdentity identity = new FederatedIdentity("knox-user-1", "KEYCLOAK", "external-subject-1",
+        "https://issuer.example.com/realms/knox", Instant.now(), attributes);
+    service.addFederatedIdentity(identity);
+
+    final Optional<FederatedIdentity> byId = service.findById(identity.getId());
+    assertTrue("Expected the identity to be found by id", byId.isPresent());
+    assertEquals("KEYCLOAK", byId.get().getProvider());
+    assertEquals("alice@example.com", byId.get().getAttribute("email"));
+
+    final Optional<FederatedIdentity> byProviderAndSubject = service.findByProviderAndSubject(
+        "KEYCLOAK", "https://issuer.example.com/realms/knox", "external-subject-1");
+    assertTrue("Expected the identity to be found by provider/issuer/subject", byProviderAndSubject.isPresent());
+    assertEquals(identity.getId(), byProviderAndSubject.get().getId());
+
+    final Optional<FederatedIdentity> missing = service.findByProviderAndSubject(
+        "KEYCLOAK", "https://issuer.example.com/realms/knox", "no-such-subject");
+    assertFalse("Did not expect an identity for an unknown subject", missing.isPresent());
+  }
+}
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index b27c7f9d8..8cff9a805 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -18,7 +18,11 @@ package org.apache.knox.gateway.service.knoxidf;
 
 import com.fasterxml.uuid.Generators;
 import com.fasterxml.uuid.impl.NameBasedGenerator;
+import com.nimbusds.jose.JOSEObjectType;
 import com.nimbusds.jose.KeyLengthException;
+import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
+import com.nimbusds.jose.proc.JOSEObjectTypeVerifier;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.http.NameValuePair;
@@ -126,6 +130,11 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
     private FederatedIdentityService federatedIdentityService;
     private boolean autoConsentEnabled;
 
+    @Override
+    public String getPrefix() {
+        return "knoxidf.";
+    }
+
     @PostConstruct
     @Override
     public void init() throws ServletException, AliasServiceException, ServiceLifecycleException, KeyLengthException {
@@ -544,8 +553,14 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
 
         try {
             final JWTokenAuthority authority = getGatewayServices().getService(ServiceType.TOKEN_SERVICE);
+            // A non-null JWS type verifier is required: federated OP id_tokens carry a "typ" header
+            // (Keycloak and most OPs set typ=JWT), and the shared token authority rejects any typ'd
+            // token outright when no verifier is supplied. Accept "JWT" and a missing typ (typ is
+            // optional per RFC 7519) so we interoperate with the range of conformant OPs.
+            final JOSEObjectTypeVerifier<SecurityContext> typeVerifier =
+                    new DefaultJOSEObjectTypeVerifier<>(new HashSet<>(Arrays.asList(JOSEObjectType.JWT, null)));
             // Verifies the signature against the OP's JWKS and checks exp/nbf.
-            if (!authority.verifyToken(idToken, Collections.singleton(new URI(jwksEndpoint)), opConfig.getSignatureAlgorithm(), null)) {
+            if (!authority.verifyToken(idToken, Collections.singleton(new URI(jwksEndpoint)), opConfig.getSignatureAlgorithm(), typeVerifier)) {
                 return error("invalid_request", "Federated id_token signature or expiry verification failed");
             }
         } catch (URISyntaxException e) {
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 513b275c3..1d0980f56 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
@@ -103,7 +103,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
 
     @Override
     public String getPrefix() {
-        return "knoxidf";
+        return "knoxidf.";
     }
 
     @PostConstruct


(knox) 21/37: KNOX-3414: bind federated id_token to the request via OIDC nonce (review finding H2)

Posted by sm...@apache.org.
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 571a9d97f9b85003266f32378b9064c51c5e291d
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 22:49:48 2026 +0200

    KNOX-3414: bind federated id_token to the request via OIDC nonce (review finding H2)
    
    The federated broker leg sent no nonce to the external OP and never checked one
    on the callback, so a signature-valid id_token minted for a different (or
    attacker-initiated) authorization request could be injected/replayed at the
    callback. Add the OIDC nonce binding (OIDC Core 3.1.2.1):
    
    - New JVM-singleton FederatedNonceStore (mirrors FederatedOpConfigurationStore),
      single-use, keyed by the federated login-session id (== the state echoed by
      the OP).
    - WebSSOResource.federatedOpLogin mints a per-request nonce, stashes it, and
      passes it to the redirect builder.
    - KnoxIDFUtils.buildFederatedOpAuthRedirect now appends &nonce=<value>
      (percent-encoded).
    - AuthorizeResource.authCallback retrieves and single-use-removes the expected
      nonce and, only after the id_token's signature/issuer/audience are verified,
      requires its nonce claim to match (verifyFederatedNonce). Missing expected
      nonce or a mismatch fails the flow.
    
    Covered by AuthorizeResourceFederatedNonceTest (match passes; mismatch, missing
    claim, and missing expected nonce all rejected).
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/AuthorizeResource.java | 36 ++++++++++-
 .../AuthorizeResourceFederatedNonceTest.java       | 75 ++++++++++++++++++++++
 .../gateway/service/knoxsso/WebSSOResource.java    | 10 ++-
 .../gateway/util/knoxidf/FederatedNonceStore.java  | 42 ++++++++++++
 .../knox/gateway/util/knoxidf/KnoxIDFUtils.java    | 11 ++--
 5 files changed, 168 insertions(+), 6 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index 8cff9a805..c2d063096 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -54,6 +54,7 @@ import org.apache.knox.gateway.services.security.token.impl.JWTToken;
 import org.apache.knox.gateway.util.JsonUtils;
 import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadata;
 import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadataStore;
+import org.apache.knox.gateway.util.knoxidf.FederatedNonceStore;
 import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration;
 import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore;
 
@@ -120,6 +121,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
     private static final String UTF_8 = StandardCharsets.UTF_8.name();
     private AuthorizeRequestMetadataStore authorizeRequestMetadataStore;
     private final FederatedOpConfigurationStore federatedOpConfigurationStore = FederatedOpConfigurationStore.getInstance(120000L);
+    private final FederatedNonceStore federatedNonceStore = FederatedNonceStore.getInstance(120000L);
 
     @Context
     private HttpServletRequest request;
@@ -291,9 +293,13 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
             return error("invalid_request", "No federated OP configuration available for the request");
         }
         // The federated callback state is single-use: invalidate it in both stores now that it has
-        // been validated and captured, so a replayed callback with the same state is rejected.
+        // been validated and captured, so a replayed callback with the same state is rejected. The
+        // nonce Knox sent to the OP was stashed under the same key (the login-session id == state);
+        // retrieve and invalidate it too so it cannot be reused.
         authorizeRequestMetadataStore.remove(state);
         federatedOpConfigurationStore.remove(state);
+        final String expectedNonce = federatedNonceStore.get(state);
+        federatedNonceStore.remove(state);
         final Pair<String, String> federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
         if (StringUtils.isBlank(federatedTokens.getLeft())) {
             return error("invalid_request", "Federated OP did not return an id_token");
@@ -304,6 +310,15 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         if (validationError != null) {
             return validationError;
         }
+        // Bind the (now signature-verified) id_token to this authorization request (OIDC Core 3.1.2.1):
+        // its nonce claim must equal the nonce Knox generated and sent to the OP for this login session.
+        // This is checked only after the token's authenticity is established, so a forged token cannot
+        // supply its own matching nonce. A missing expected nonce (e.g. expired/replayed state) or a
+        // mismatch fails the flow.
+        final Response nonceError = verifyFederatedNonce(expectedNonce, federatedIdToken);
+        if (nonceError != null) {
+            return nonceError;
+        }
         final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName());
         return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight()));
     }
@@ -581,6 +596,25 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         return null;
     }
 
+    /**
+     * Verifies the OIDC {@code nonce} binding for a federated login (OIDC Core 3.1.2.1). The
+     * {@code expectedNonce} is the value Knox generated for this login session and sent to the OP;
+     * it must equal the {@code nonce} claim of the (already signature-verified) id_token. Callers
+     * must invoke this only after {@link #validateFederatedIdToken} succeeds so a forged token cannot
+     * assert its own nonce.
+     *
+     * @return an error {@link Response} on absence/mismatch, or {@code null} when the nonce matches.
+     */
+    Response verifyFederatedNonce(final String expectedNonce, final JWT idToken) {
+        if (StringUtils.isBlank(expectedNonce)) {
+            return error("invalid_request", "Missing or expired federated login nonce");
+        }
+        if (!expectedNonce.equals(idToken.getClaim(NONCE))) {
+            return error("invalid_request", "Federated id_token nonce mismatch");
+        }
+        return null;
+    }
+
     private FederatedIdentity resolveFederatedIdentity(final JWT jwt, String opName) {
         final String issuer = jwt.getIssuer();
         final String subject = jwt.getSubject();
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java
new file mode 100644
index 000000000..0c2ae1577
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.NONCE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+/**
+ * Verifies the federated OIDC {@code nonce} binding (review finding H2). Knox mints a nonce for each
+ * federated login session, sends it to the OP, and — once the returned id_token's signature/issuer/
+ * audience have been verified — requires the id_token's {@code nonce} claim to equal that value. This
+ * defeats id_token replay/injection: a token minted for a different (or attacker-initiated) request
+ * carries a different nonce and is rejected.
+ */
+public class AuthorizeResourceFederatedNonceTest {
+
+  private static final String NONCE_VALUE = "6d1c7a90-4b2e-4c1a-9f3d-0a1b2c3d4e5f";
+
+  private static JWT idTokenWithNonce(final String nonce) {
+    final JWT idToken = EasyMock.createNiceMock(JWT.class);
+    EasyMock.expect(idToken.getClaim(NONCE)).andReturn(nonce).anyTimes();
+    EasyMock.replay(idToken);
+    return idToken;
+  }
+
+  @Test
+  public void testMatchingNoncePasses() {
+    final Response result = new AuthorizeResource().verifyFederatedNonce(NONCE_VALUE, idTokenWithNonce(NONCE_VALUE));
+    assertNull("A matching nonce must pass (null == no error).", result);
+  }
+
+  @Test
+  public void testMismatchedNonceIsRejected() {
+    final Response result = new AuthorizeResource().verifyFederatedNonce(NONCE_VALUE, idTokenWithNonce("some-other-nonce"));
+    assertNotNull("A mismatched nonce must be rejected.", result);
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus());
+  }
+
+  @Test
+  public void testMissingClaimInTokenIsRejected() {
+    final Response result = new AuthorizeResource().verifyFederatedNonce(NONCE_VALUE, idTokenWithNonce(null));
+    assertNotNull("An id_token without a nonce claim must be rejected.", result);
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus());
+  }
+
+  @Test
+  public void testMissingExpectedNonceIsRejected() {
+    // No stored nonce (expired/replayed state) must fail closed rather than accept any token.
+    final Response result = new AuthorizeResource().verifyFederatedNonce(null, idTokenWithNonce(NONCE_VALUE));
+    assertNotNull("A missing expected nonce must be rejected.", result);
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus());
+  }
+}
diff --git a/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java b/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java
index eea58f1dc..ce2787572 100644
--- a/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java
+++ b/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java
@@ -41,6 +41,7 @@ import org.apache.knox.gateway.util.SetCookieHeader;
 import org.apache.knox.gateway.util.Tokens;
 import org.apache.knox.gateway.util.Urls;
 import org.apache.knox.gateway.util.WhitelistUtils;
+import org.apache.knox.gateway.util.knoxidf.FederatedNonceStore;
 import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration;
 import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore;
 import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils;
@@ -70,6 +71,7 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.Set;
+import java.util.UUID;
 
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 import static javax.ws.rs.core.MediaType.APPLICATION_XML;
@@ -120,6 +122,7 @@ public class WebSSOResource {
   private String tokenIssuer;
   private TokenStateService tokenStateService;
   private final FederatedOpConfigurationStore federatedOpConfigurationStore = FederatedOpConfigurationStore.getInstance(120000L);
+  private final FederatedNonceStore federatedNonceStore = FederatedNonceStore.getInstance(120000L);
 
   private String sameSiteValue;
 
@@ -243,7 +246,12 @@ public class WebSSOResource {
       final FederatedOpConfiguration federatedOpConfiguration = federatedOpConfig.get();
       //keep only the selected federated OP in the cache -> we can easily get it in the AuthorizeResource.authCallback endpoint
       federatedOpConfigurationStore.put(loginSessionId, Set.of(federatedOpConfiguration));
-      final String federatedOpAuthRedirect = KnoxIDFUtils.buildFederatedOpAuthRedirect(federatedOpConfiguration, loginSessionId);
+      // Generate a per-request nonce, send it to the OP, and stash it keyed by the login-session id
+      // (== the state echoed back by the OP). AuthorizeResource.authCallback verifies the returned
+      // id_token's nonce claim against this value, binding the id_token to this authorization request.
+      final String nonce = UUID.randomUUID().toString();
+      federatedNonceStore.put(loginSessionId, nonce);
+      final String federatedOpAuthRedirect = KnoxIDFUtils.buildFederatedOpAuthRedirect(federatedOpConfiguration, loginSessionId, nonce);
       return Response.seeOther(java.net.URI.create(federatedOpAuthRedirect)).build();
     } else {
       return KnoxIDFUtils.error("invalid_request", "Cannot load federated op config associated with login session");
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedNonceStore.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedNonceStore.java
new file mode 100644
index 000000000..a3b30bf70
--- /dev/null
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedNonceStore.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+/**
+ * Holds the OIDC {@code nonce} that Knox generates and sends to a federated OP, keyed by the
+ * federated login-session id (the {@code state} echoed by the OP). The value is written when the OP
+ * authorization redirect is built and read once when the OP callback is processed, so the returned
+ * id_token's {@code nonce} claim can be bound to this specific authorization request. Like the other
+ * KnoxIDF artifact stores this is a JVM singleton (the redirect is built in one topology/webapp and
+ * the callback handled in another, within the same JVM) and its entries are single-use: callers
+ * {@code remove()} the nonce after verifying it so a replayed callback cannot reuse it.
+ */
+public class FederatedNonceStore extends KnoxIDFArtifactStore<String> {
+
+    private static FederatedNonceStore instance;
+
+    private FederatedNonceStore(long ttl) {
+        super(ttl);
+    }
+
+    public static synchronized FederatedNonceStore getInstance(long ttl) {
+        if (instance == null) {
+            instance = new FederatedNonceStore(ttl);
+        }
+        return instance;
+    }
+}
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
index 423ad37ad..f2f0e5ae3 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
@@ -102,17 +102,20 @@ public class KnoxIDFUtils {
         return new AuthorizeRequestMetadata(clientId, null, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod);
     }
 
-    public static String buildFederatedOpAuthRedirect(final FederatedOpConfiguration federatedOpConfiguration, final String federatedState) {
+    public static String buildFederatedOpAuthRedirect(final FederatedOpConfiguration federatedOpConfiguration, final String federatedState, final String nonce) {
         // URL-encode every value placed into the query string. client_id and the callback URI
-        // (which itself contains ':' '/' '?' etc.) and the state must be percent-encoded or the
-        // OP receives a malformed/parameter-split URL. CODE_RESPONSE_TYPE and OPENID_SCOPE are
+        // (which itself contains ':' '/' '?' etc.), the state and the nonce must be percent-encoded
+        // or the OP receives a malformed/parameter-split URL. CODE_RESPONSE_TYPE and OPENID_SCOPE are
         // fixed "key=value" literals with no reserved characters, so they are appended as-is.
+        // The nonce binds the returned id_token to this authorization request (OIDC Core 3.1.2.1);
+        // it is verified against the id_token's nonce claim when the OP callback is processed.
         return federatedOpConfiguration.getAuthorizeEndpoint()
                 + "?" + KnoxIDFConstants.CLIENT_ID + "=" + urlEncode(federatedOpConfiguration.getClientId())
                 + "&" + KnoxIDFConstants.REDIRECT_URI + "=" + urlEncode(federatedOpConfiguration.getAuthorizeCallback())
                 + "&" + KnoxIDFConstants.CODE_RESPONSE_TYPE
                 + "&" + KnoxIDFConstants.OPENID_SCOPE
-                + "&" + KnoxIDFConstants.STATE + "=" + urlEncode(federatedState);
+                + "&" + KnoxIDFConstants.STATE + "=" + urlEncode(federatedState)
+                + "&" + KnoxIDFConstants.NONCE + "=" + urlEncode(nonce);
     }
 
     private static String urlEncode(final String value) {


(knox) 22/37: KNOX-3414: atomically consume the refresh token before rotation (review finding M1)

Posted by sm...@apache.org.
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 5926b85af210a7efb2f681924e8f611fb19388c2
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:18:02 2026 +0200

    KNOX-3414: atomically consume the refresh token before rotation (review finding M1)
    
    handleRefreshToken revoked the presented refresh token with revokeToken() only
    after validation, then minted the replacement. On DefaultTokenStateService
    revokeToken is check-then-act, so two concurrent redemptions of the same refresh
    token could both pass validation and both mint a new access/refresh pair. (The
    JDBC/Derby path is already atomic via a PK DELETE; this is the in-memory gap.)
    
    Replace revokeToken with the atomic consumeToken as a single-use claim performed
    BEFORE issuance: exactly one concurrent caller wins and rotates, the losers get
    invalid_grant and mint nothing. Mirrors the consume-before-issue guard already on
    the authorization_code grant (finding 2.4).
    
    Covered by TokenResourceRefreshTokenRotationTest (a lost consume yields
    invalid_grant with no issuance).
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/TokenResource.java     |  21 +++-
 .../TokenResourceRefreshTokenRotationTest.java     | 134 +++++++++++++++++++++
 2 files changed, 150 insertions(+), 5 deletions(-)

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 8c463ab1f..c0c71a452 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
@@ -204,21 +204,32 @@ public class TokenResource extends PasscodeTokenResourceBase {
         return responseMap;
     }
 
-    private Response handleRefreshToken() {
+    // Package-private for testability (the single-use rotation guard is exercised by
+    // TokenResourceRefreshTokenRotationTest); not part of the public resource API.
+    Response handleRefreshToken() {
         try {
             final String refreshTokenParam = getRequestParam(REFRESH_TOKEN);
             final String refreshTokenId = TokenUtils.getTokenId(refreshTokenParam);
             final TokenMetadata refreshTokenMetadata = tokenStateService.getTokenMetadata(refreshTokenId);
             validateRefreshTokenGrant(refreshTokenParam, refreshTokenId, refreshTokenMetadata);
-            // Valid refresh token -> issue new access token and new refresh token (rotation)
+
+            // Rotation is single-use: atomically consume (revoke) the presented refresh token BEFORE
+            // issuing its replacement. consumeToken is an atomic claim -- exactly one of N concurrent
+            // redemptions wins -- so two concurrent refreshes cannot both mint a new token pair from
+            // the same refresh token. (DefaultTokenStateService otherwise has a check-then-act race in
+            // revokeToken; the JDBC path is already atomic via a PK DELETE.) A lost claim means another
+            // request already redeemed/rotated this token, so reject it as invalid_grant. This mirrors
+            // the consume-before-issue guard on the authorization_code grant (see handleAuthorizationCodeFlow).
+            if (!tokenStateService.consumeToken(refreshTokenId)) {
+                return error("invalid_grant", "Refresh token has already been redeemed");
+            }
+
+            // Valid, freshly-consumed refresh token -> issue new access token and new refresh token (rotation)
             final String userName = refreshTokenMetadata.getUserName();
             final String scope = refreshTokenMetadata.getMetadata(SCOPE);
             final Map<String, Object> userParams = userParamsProvider.getParamsFor(userName, scope);
             userParams.put(SCOPE, scope);
 
-            // Revoke old refresh token (rotation)
-            tokenStateService.revokeToken(refreshTokenId);
-
             // Build new tokens
             final UserContext userContext = new UserContext(userName, null, userParams);
             final TokenResponseContext resp = getTokenResponse(userContext);
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java
new file mode 100644
index 000000000..90b7346f2
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenMetadataType;
+import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.impl.TokenMAC;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies single-use enforcement of the {@code refresh_token} grant (review finding M1). The
+ * presented refresh token must be atomically consumed BEFORE its replacement is issued: exactly one
+ * of N concurrent redemptions wins the consume and rotates; the losers are rejected with
+ * {@code invalid_grant} and no new token pair is minted. This closes the check-then-act window that
+ * existed when rotation used {@code revokeToken} on the in-memory backend. Mirrors the
+ * authorization_code single-use guard (see {@link TokenResourceAuthCodeReplayTest}).
+ */
+public class TokenResourceRefreshTokenRotationTest {
+
+  // A UUID so TokenUtils.getTokenId returns it verbatim (no JWT parsing needed).
+  private static final String REFRESH_TOKEN_ID = "11111111-2222-3333-4444-555555555555";
+  private static final String CLIENT = "client-abc";
+  private static final String USER_NAME = "alice";
+  private static final long ISSUE_TIME = 1_700_000_000_000L;
+  private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
+
+  private TokenStateService tokenStateService;
+  private TestableTokenResource resource;
+  private final AtomicInteger issuedCount = new AtomicInteger();
+
+  /** Field injection plus a stub for the token-mint step so the rotation guard is tested in isolation. */
+  final class TestableTokenResource extends TokenResource {
+    void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) {
+      this.tokenStateService = tss;
+      this.tokenMAC = mac;
+      this.request = req;
+    }
+
+    @Override
+    protected TokenResponseContext getTokenResponse(final UserContext context) {
+      issuedCount.incrementAndGet();
+      return new TokenResponseContext(null, "issued", Response.ok());
+    }
+  }
+
+  private static String wireSecret(final String tokenId, final String rawPasscode) {
+    final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8))
+        + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8));
+    return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray());
+    final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE);
+
+    final TokenMetadata refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.getUserName()).andReturn(USER_NAME).anyTimes();
+    EasyMock.replay(refreshTokenMetadata);
+
+    final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes();
+    EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes();
+    EasyMock.replay(clientMetadata);
+
+    tokenStateService = EasyMock.createNiceMock(TokenStateService.class);
+    EasyMock.expect(tokenStateService.getTokenMetadata(REFRESH_TOKEN_ID)).andReturn(refreshTokenMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID))
+        .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes();
+    // consumeToken behaviour is set per-test (win vs. lose) before replay().
+
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getParameter(REFRESH_TOKEN)).andReturn(REFRESH_TOKEN_ID).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes();
+    EasyMock.replay(req);
+
+    resource = new TestableTokenResource();
+    resource.inject(tokenStateService, tokenMAC, req);
+  }
+
+  @Test
+  public void testAlreadyRedeemedRefreshTokenIsRejectedWithoutIssuing() {
+    // A concurrent redemption already consumed the token: this consume loses.
+    EasyMock.expect(tokenStateService.consumeToken(REFRESH_TOKEN_ID)).andReturn(false).once();
+    EasyMock.replay(tokenStateService);
+
+    final Response response = resource.handleRefreshToken();
+
+    assertEquals("A refresh token already consumed by a concurrent rotation must be rejected.",
+        Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertTrue("The error body should identify the invalid_grant condition.",
+        String.valueOf(response.getEntity()).contains("invalid_grant"));
+    assertEquals("A losing redemption must not mint any token.", 0, issuedCount.get());
+    // Proves rotation now goes through the atomic consume path rather than revokeToken.
+    EasyMock.verify(tokenStateService);
+  }
+}


(knox) 34/37: KNOX-3414: add Identity Federation (OIDC Provider) documentation book

Posted by sm...@apache.org.
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 0bcb52a38b79d885463b999d35d816fc235fae05
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 01:27:31 2026 +0200

    KNOX-3414: add Identity Federation (OIDC Provider) documentation book
    
    Add a new top-level knox-site navigation tab documenting KnoxIDF (Apache
    Knox as an OAuth2/OIDC Authorization Server), with seven chapters: Overview,
    Getting Started, Endpoint Reference, Configuration Reference, Security,
    Federation, and Operations. Enable Mermaid diagrams via pymdownx.superfences
    and add the architecture, federated-login, and consent images.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../docs/assets/images/knoxidf/architecture.png    | Bin 0 -> 319038 bytes
 .../docs/assets/images/knoxidf/consent_page.png    | Bin 0 -> 87171 bytes
 .../assets/images/knoxidf/login_page_federated.png | Bin 0 -> 7329 bytes
 knox-site/docs/knoxidf/configuration.md            | 174 ++++++++++++
 knox-site/docs/knoxidf/endpoints.md                | 299 +++++++++++++++++++++
 knox-site/docs/knoxidf/federation.md               | 221 +++++++++++++++
 knox-site/docs/knoxidf/getting_started.md          | 284 +++++++++++++++++++
 knox-site/docs/knoxidf/index.md                    |  92 +++++++
 knox-site/docs/knoxidf/operations.md               | 182 +++++++++++++
 knox-site/docs/knoxidf/security.md                 | 183 +++++++++++++
 knox-site/mkdocs.yml                               |  14 +-
 11 files changed, 1448 insertions(+), 1 deletion(-)

diff --git a/knox-site/docs/assets/images/knoxidf/architecture.png b/knox-site/docs/assets/images/knoxidf/architecture.png
new file mode 100644
index 000000000..38294a7d4
Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/architecture.png differ
diff --git a/knox-site/docs/assets/images/knoxidf/consent_page.png b/knox-site/docs/assets/images/knoxidf/consent_page.png
new file mode 100644
index 000000000..51b552dc4
Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/consent_page.png differ
diff --git a/knox-site/docs/assets/images/knoxidf/login_page_federated.png b/knox-site/docs/assets/images/knoxidf/login_page_federated.png
new file mode 100644
index 000000000..eac3689d8
Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/login_page_federated.png differ
diff --git a/knox-site/docs/knoxidf/configuration.md b/knox-site/docs/knoxidf/configuration.md
new file mode 100644
index 000000000..a832a0bbe
--- /dev/null
+++ b/knox-site/docs/knoxidf/configuration.md
@@ -0,0 +1,174 @@
+<!--
+   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.
+-->
+
+# Configuration Reference
+
+KnoxIDF is configured through two layers:
+
+- **Topology `KNOXIDF` service parameters** — per-deployment behavior (token TTLs, consent,
+  federation, user attributes). These live inside the `<service><role>KNOXIDF</role>…</service>`
+  element of a topology file.
+- **`gateway-site.xml` properties** — gateway-wide concerns shared with the rest of Knox (signing
+  keys, persistence/database, trusted-issuer cache tuning).
+
+This chapter lists every parameter, its default, and its effect.
+
+## How KNOXIDF service parameters are read
+
+### The `knoxidf.` → `knox.token.` prefix passthrough
+
+KnoxIDF reuses Knox's existing server-managed token machinery. Any `KNOXIDF` service parameter
+written with the **`knoxidf.` prefix** has that prefix stripped and the remainder passed through
+to the underlying token configuration. So the topology parameter:
+
+```xml
+<param>
+    <name>knoxidf.knox.token.ttl</name>
+    <value>86400000</value>
+</param>
+```
+
+is delivered to the token layer as `knox.token.ttl`. This lets you set any
+[KnoxToken](../config_knox_token.md) parameter on the KnoxIDF service by prefixing it with
+`knoxidf.`.
+
+!!! note "Access-token TTL is managed by Knox"
+    KnoxIDF issues **server-managed** tokens: the access-token lifetime is governed by the token
+    layer (`knoxidf.knox.token.ttl`) and, on the token-exchange topology, by the `JWTProvider`'s
+    `knox.token.exp.server-managed=true`. Clients cannot request an arbitrary lifetime.
+
+## Core `KNOXIDF` service parameters
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `knoxidf.knox.token.ttl` | (token-layer default) | Access-token lifetime in **milliseconds** (passthrough to `knox.token.ttl`). |
+| `knoxidf.knox.token.limit.per.user` | (token-layer default) | Max concurrent server-managed tokens per user; `-1` = unlimited (passthrough to `knox.token.limit.per.user`). |
+| `refresh.token.ttl` | `86400000` (24 h) | Refresh-token lifetime in milliseconds. Refresh tokens are issued only when the `offline_access` scope is granted, and are rotated on each use. |
+| `knoxidf.auto.consent.enabled` | `false` | When `true`, the [consent screen](security.md#consent) is skipped. This is a **server-side** decision and is never read from a client request parameter. |
+| `knoxidf.client.registration.anonymous.allowed` | `false` | When `true`, [dynamic client registration](security.md#dynamic-client-registration) accepts anonymous callers. Secure by default. |
+| `token.exchange.topology.name` | (none) | Name of the token-exchange topology (fronted by `JWTProvider`) to which the `token_endpoint` and `userinfo_endpoint` are redirected in discovery. See the [two-topology model](getting_started.md#3-deploy-the-knoxidf-topologies). |
+| `federated.op.names` | (none) | Comma-separated list of federated OP logical names to enable. See [Federated OP parameters](#federated-op-parameters). |
+
+!!! warning "Secure-by-default flags"
+    Both `knoxidf.auto.consent.enabled` and `knoxidf.client.registration.anonymous.allowed`
+    default to **`false`**. The sample topologies set them to `true` only to keep experimentation
+    frictionless — review them before any non-development deployment.
+
+## Federated OP parameters
+
+Each name listed in `federated.op.names` is configured with a block of
+`federated.op.<name>.<suffix>` parameters. See [Federation](federation.md) for the full flow and a
+complete example.
+
+| Suffix | Required | Default | Description |
+|--------|----------|---------|-------------|
+| `enabled` | — | `false` | Activates this OP. Only enabled OPs are offered on the login page. |
+| `issuer` | **Yes** | — | Expected `iss` of the OP's id_token. Validated exactly — must match the OP's issuer. |
+| `jwks.endpoint` | **Yes** | — | OP JWKS URL used to verify the id_token signature. |
+| `clientId` | Yes | — | Knox's client id at the OP; must appear in the id_token `aud`. |
+| `clientSecret` | Conditional | — | Knox's client secret at the OP (plaintext). Prefer `clientSecret.alias`. |
+| `clientSecret.alias` | Conditional | — | Alias name resolved via `AliasService`. **Takes precedence** over `clientSecret` and **fails closed** if unresolvable. See [Security → Secret handling](security.md#secret-handling). |
+| `authorize.endpoint` | Yes | — | OP authorization endpoint Knox redirects the user to. |
+| `token.endpoint` | Yes | — | OP token endpoint for the back-channel code exchange. |
+| `userinfo.endpoint` | No | — | OP UserInfo endpoint. |
+| `discovery.endpoint` | No | — | OP discovery document URL (alternative to listing endpoints individually). |
+| `authorize.callback` | Yes | — | The Knox callback URL registered at the OP (`…/knoxidf/api/v1/authorize/callback`). |
+| `signature.algorithm` | No | `RS256` | Expected id_token signing algorithm. |
+
+## User parameters and claims
+
+KnoxIDF can enrich issued tokens with additional claims — statically configured claims and, for
+local users, attributes resolved from a **user-parameter provider**.
+
+### Hard-coded claim mappings
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `knox.token.hardcoded.claim.mappings` | (none) | `;`-separated list of `key=value` pairs added as claims to every issued token. |
+
+### LDAP user-parameter provider
+
+If `user.params.provider.ldap.url` is set, KnoxIDF looks up the authenticated user in LDAP and adds
+the resolved attributes to the token (and to the UserInfo response). If it is **absent**, an
+`EmptyUserParamsProvider` is used and no LDAP lookup occurs.
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `user.params.provider.ldap.url` | (none) | LDAP(S) URL. Its presence selects the LDAP provider; absence selects the no-op provider. |
+| `user.params.provider.ldap.baseDn` | `dc=hadoop,dc=apache,dc=org` | Search base DN. |
+| `user.params.provider.ldap.userDnTemplate` | `uid=%s,ou=people,dc=hadoop,dc=apache,dc=org` | DN template; `%s` is replaced with the (escaped) username. |
+| `user.params.provider.ldap.systemUser` | `uid=admin,ou=people,dc=hadoop,dc=apache,dc=org` | Bind DN for attribute lookups. |
+| `user.params.provider.ldap.systemPasswordAlias` | — | **Required** alias for the system-user password. There is no plaintext fallback — if the alias is absent or unresolvable, initialization fails with an `IllegalStateException` (fail-closed). |
+
+## Gateway-site properties
+
+These are set in `$KNOX_HOME/conf/gateway-site.xml` and shared with the rest of Knox.
+
+### Signing keys
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `gateway.signing.key.alias` | `gateway-identity` | Alias of the primary key used to sign KnoxIDF-issued JWTs. |
+| `gateway.signing.key.aliases.additional` | `none` | Comma-separated additional signing-key aliases to **also publish** on the JWKS endpoint. This is the mechanism behind zero-downtime [signing-key rotation](operations.md#signing-key-rotation). `none` means no additional keys. |
+| `gateway.signing.keystore.name` | (gateway identity keystore) | Keystore holding the signing key(s). |
+| `gateway.signing.keystore.type` | (gateway default) | Keystore type (e.g. `JKS`, `PKCS12`). |
+| `gateway.signing.keystore.password.alias` | (gateway default) | Alias of the keystore password. |
+
+### Persistence and database
+
+KnoxIDF's [federated-identity store](operations.md#federated-identity-persistence) and the trusted-issuer
+registry share Knox's database configuration. With no external database configured, KnoxIDF
+self-provisions an **embedded Derby** database (the same physical store used by token state), so no
+setup is required to get started.
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `gateway.database.type` | `none` | Database backend: `none` / `derbydb` select the embedded self-provisioning Derby store; a real external type (`postgresql`, `mysql`, `oracle`, …) selects the JDBC-backed store. |
+| `gateway.database.connection.url` | (none) | Full JDBC URL (overrides host/port/name if set). |
+| `gateway.database.host` | (none) | Database host (when not using a full connection URL). |
+| `gateway.database.port` | (none) | Database port. |
+| `gateway.database.name` | `GATEWAY_DATABASE` | Database/schema name. |
+| `gateway.database.ssl.enabled` | `false` | Enable TLS to the database. |
+| `gateway.database.ssl.truststore.path` / `.alias` | (none) | Truststore path / password alias for the database TLS connection. |
+
+Database credentials are supplied as aliases (`gateway_database_user`,
+`gateway_database_password`) — see [Getting Started](getting_started.md#2-install-and-start-knox).
+
+### Trusted OIDC issuer registry
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `gateway.trustedoidcissuer.discovery.cache.ttl.secs` | `600` | How long a fetched issuer JWKS/discovery document is cached before re-fetch. |
+| `gateway.trustedoidcissuer.discovery.connect.timeout.ms` | `3000` | Connect timeout when fetching an issuer's discovery/JWKS document. |
+| `gateway.trustedoidcissuer.discovery.read.timeout.ms` | `10000` | Read timeout for the same fetch. |
+| `gateway.trusted.oidc.issuer.max.issuers` | `10000` | Upper bound on the number of registered trusted issuers. Registration returns `409 issuer_limit_reached` once reached. |
+
+### Provider-related properties (sample topologies)
+
+These are not KnoxIDF parameters but appear in the sample federation topologies:
+
+| Property | Description |
+|----------|-------------|
+| `jwt.expected.issuer` | Expected issuer enforced by a `JWTProvider` fronting the token-exchange topology. |
+| `sso.unauthenticated.path.list` | On an `SSOCookieProvider` front topology, the `;`-separated list of KnoxIDF paths reachable before login (callback, JWKS, discovery, registration). See [Federation](federation.md#front-topology-for-federation). |
+
+## See also
+
+- [Getting Started](getting_started.md) — worked topology examples.
+- [Security](security.md) — what the secure-by-default flags protect against.
+- [Federation](federation.md) — configuring external OPs.
+- [Operations](operations.md) — persistence backends and signing-key rotation.
diff --git a/knox-site/docs/knoxidf/endpoints.md b/knox-site/docs/knoxidf/endpoints.md
new file mode 100644
index 000000000..c3c5f14fb
--- /dev/null
+++ b/knox-site/docs/knoxidf/endpoints.md
@@ -0,0 +1,299 @@
+<!--
+   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.
+-->
+
+# Endpoint Reference
+
+KnoxIDF exposes a set of REST endpoints aligned with the standard OpenID Connect expectations,
+so clients configured for a Keycloak-like provider work against Knox with minimal changes.
+
+## Base path and URL structure
+
+All KnoxIDF endpoints share the base path `knoxidf/api/v1`. As with every Knox service, the
+gateway prefixes the topology name, so a fully-qualified URL looks like:
+
+```
+https://{knox-host}:8443/gateway/{topology}/knoxidf/api/v1/{endpoint}
+```
+
+The administrative endpoints (service role `KNOXIDF_ADMIN`) live under a separate base path,
+`knoxidf/admin/v1`.
+
+!!! tip "Always read endpoint URLs from discovery"
+    Do not hard-code endpoint paths in clients. Fetch the
+    [discovery document](#discovery-endpoint) and use the URLs it advertises. When a
+    `token.exchange.topology.name` is configured, the `token_endpoint` and `userinfo_endpoint`
+    are deliberately rewritten to point at the token-exchange topology — discovery reflects
+    that, hard-coded paths will not.
+
+## Endpoint summary
+
+| Endpoint | Path | Methods | Role |
+|----------|------|---------|------|
+| [Discovery](#discovery-endpoint) | `knoxidf/api/v1/.well-known/openid-configuration` | GET | `KNOXIDF` |
+| [Authorization](#authorization-endpoint) | `knoxidf/api/v1/authorize` | GET, POST | `KNOXIDF` |
+| [Federated callback](#federated-callback) | `knoxidf/api/v1/authorize/callback` | GET | `KNOXIDF` |
+| [Token](#token-endpoint) | `knoxidf/api/v1/token` | POST | `KNOXIDF` |
+| [UserInfo](#userinfo-endpoint) | `knoxidf/api/v1/userinfo` | GET | `KNOXIDF` |
+| [JWKS](#jwks-endpoint) | `knoxidf/api/v1/jwks` | GET | `KNOXIDF` |
+| [Client Registration](#client-registration-endpoint) | `knoxidf/api/v1/client/register` | POST | `KNOXIDF` |
+| [Consent page](#consent-page) | `authConsent` | GET, POST | `KNOXIDF` |
+| [Trusted OIDC Issuers (admin)](#trusted-oidc-issuers-admin) | `knoxidf/admin/v1/trusted-oidc-issuers` | GET, POST, DELETE | `KNOXIDF_ADMIN` |
+
+---
+
+## Discovery endpoint
+
+`GET /knoxidf/api/v1/.well-known/openid-configuration`
+
+Returns the OpenID Connect Discovery document. All endpoint URLs are built dynamically from the
+request's base URI, so the document is always correct for the topology it is served from.
+
+Example document:
+
+```json
+{
+  "issuer": "https://knox:8443/gateway/knoxidf-ldap/knoxidf",
+  "authorization_endpoint": "https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/authorize",
+  "token_endpoint": "https://knox:8443/gateway/knoxidf-token/knoxidf/api/v1/token",
+  "userinfo_endpoint": "https://knox:8443/gateway/knoxidf-token/knoxidf/api/v1/userinfo",
+  "registration_endpoint": "https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/client",
+  "jwks_uri": "https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/jwks",
+  "response_types_supported": ["code"],
+  "subject_types_supported": ["public"],
+  "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
+  "client_id_metadata_document_supported": false,
+  "grant_types_supported": ["authorization_code", "refresh_token"],
+  "scopes_supported": ["openid", "profile", "email", "offline_access"],
+  "id_token_signing_alg_values_supported": ["RS256"],
+  "code_challenge_methods_supported": ["S256"]
+}
+```
+
+Notable metadata:
+
+- **`subject_types_supported: ["public"]`** — Knox derives a shared (non-pairwise) `sub`, the
+  same value for every client.
+- **`token_endpoint_auth_methods_supported: ["client_secret_post", "none"]`** — the token
+  endpoint reads client credentials only from the request body (`client_secret_post`);
+  public clients use PKCE with no secret (`none`). HTTP Basic (`client_secret_basic`) is
+  intentionally **not** advertised because it is not honored.
+- **`code_challenge_methods_supported: ["S256"]`** — only S256 PKCE is accepted; `plain` is
+  rejected.
+- **`client_id_metadata_document_supported: false`** — Knox does not resolve a URL-style
+  `client_id` as a Client ID Metadata Document (OAuth CIMD draft, referenced by the MCP
+  authorization spec); clients must use dynamic registration instead.
+
+---
+
+## Authorization endpoint
+
+`GET|POST /knoxidf/api/v1/authorize`
+
+Begins the Authorization Code flow. Validates the request, checks (or collects) user consent,
+and redirects back to the client `redirect_uri` with an authorization `code` and the echoed
+`state`. If consent has not yet been granted for this (user, client, scopes), the browser is
+first redirected to the [consent page](#consent-page).
+
+Request parameters:
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `response_type` | Yes | `code`, `id_token`, or `code id_token`. |
+| `client_id` | Yes | A registered client identifier. |
+| `redirect_uri` | Yes | Must match the client's registered `redirect_uris`. |
+| `scope` | No | Space-delimited scopes. Defaults to `openid profile email offline_access`. |
+| `state` | No | Opaque CSRF value, echoed back in the redirect. |
+| `nonce` | No | Bound to the issued ID token. |
+| `code_challenge` | No (PKCE) | Base64url S256 hash of the `code_verifier`. |
+| `code_challenge_method` | Required if `code_challenge` present | Must be `S256`; `plain` is rejected. |
+
+**Success:** `302` redirect to `{redirect_uri}?code={auth_code}&state={state}`.
+
+**Errors:** a JSON body `{"error": "...", "error_description": "..."}` — `invalid_request`
+(unknown `client_id`, bad `redirect_uri`, missing parameters, unsupported PKCE method) or
+`invalid_scope` (a requested scope is not in the client's allowed scopes). When consent is
+required, a `303` redirect to the consent page.
+
+### Federated callback
+
+`GET /knoxidf/api/v1/authorize/callback`
+
+Back-channel callback invoked by an external OIDC Provider during [federation](federation.md).
+Exchanges the OP authorization `code` for the OP's tokens, **validates the OP `id_token`**
+(signature via JWKS, issuer, audience, nonce, and `sub` presence), resolves or persists the
+federated identity, then issues a Knox authorization code and redirects to the original client
+`redirect_uri`.
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `code` | Yes | Authorization code from the federated OP. |
+| `state` | Yes | Must match a live entry in the authorize-request store. |
+
+This endpoint must be reachable without prior Knox authentication (wired as `anon`, or listed
+in `sso.unauthenticated.path.list`).
+
+---
+
+## Token endpoint
+
+`POST /knoxidf/api/v1/token` &nbsp; `Content-Type: application/x-www-form-urlencoded`
+
+Issues tokens. Supports the **Client Credentials**, **Authorization Code**, and **Refresh
+Token** grants. Client authentication is enforced here — see
+[Security](security.md#token-endpoint-client-authentication).
+
+### Authorization Code grant
+
+Redeems a one-time authorization code. The code is atomically consumed (single-use); a public
+client proves possession via PKCE `code_verifier`, a confidential client via `client_secret`.
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `grant_type` | Yes | `authorization_code`. |
+| `code` | Yes | The authorization code from `/authorize`. |
+| `redirect_uri` | Yes | Must match the URI stored with the code. |
+| `client_id` | Yes | Must match the client that obtained the code. |
+| `client_secret` | Conditional | Required when no `code_challenge` was stored. |
+| `code_verifier` | Conditional | Required when a `code_challenge` was stored at authorize time. |
+
+### Refresh Token grant
+
+Rotates a refresh token: atomically consumes the presented token and issues a new
+access-token / refresh-token pair. Requires `client_secret`.
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `grant_type` | Yes | `refresh_token`. |
+| `refresh_token` | Yes | A previously issued refresh token. |
+| `client_id` | Yes | Must match the client bound to the refresh token. |
+| `client_secret` | Yes | Client secret. |
+
+**Success (`200`):**
+
+```json
+{
+  "access_token": "<JWT>",
+  "token_id": "<UUID>",
+  "token_type": "Bearer",
+  "expires_in": 1699999999999,
+  "managed_token": "true",
+  "id_token": "<JWT>",
+  "refresh_token": "<JWT>",
+  "passcode": "<Base64(tokenId)::Base64(passcode)>"
+}
+```
+
+`refresh_token` is present only when the scope includes `offline_access`. The `id_token`
+carries `sub`, `iss`, `aud` (= `client_id`), `exp`, `iat`, and `nonce` (when supplied); for
+federated users it additionally carries `federated_idp`, `federated_sub`, and `federated_iss`
+plus any allowed profile claims (`preferred_username`, `email`, `email_verified`,
+`given_name`, `family_name`, `name`, `locale`).
+
+**Errors:** `invalid_grant` (missing/expired/replayed code, `redirect_uri` or `client_id`
+mismatch, PKCE failure, bad `client_secret`, disabled/expired refresh token) or
+`invalid_request` (unsupported `grant_type`).
+
+---
+
+## UserInfo endpoint
+
+`GET /knoxidf/api/v1/userinfo`
+
+Returns OIDC UserInfo claims for a valid bearer access token. The upstream `JWTProvider`
+validates the token and hands the token identity to this resource; the endpoint never reads the
+raw `Authorization` header itself. For a federated user it returns the internal Knox `sub`, the
+`idp` name, `federated_sub`, `federated_iss`, and any allowed profile claims; for a local user
+it returns whatever the configured [user-parameter provider](configuration.md#user-parameters-and-claims)
+resolves.
+
+**Errors:** `invalid_request` when no token identity is present; `401` with
+`WWW-Authenticate: Bearer error="invalid_token"` for an expired, revoked, or unknown token.
+
+---
+
+## JWKS endpoint
+
+`GET /knoxidf/api/v1/jwks`
+
+Publishes the gateway's public signing key(s) as a JWK Set so clients and resource servers can
+verify KnoxIDF-issued JWTs. One JWK is published per configured signing-key alias, each keyed by
+the SHA-256 thumbprint of its public key as the `kid`. This is what makes signing-key rotation
+transparent to verifiers — see [Operations → Signing-key rotation](operations.md#signing-key-rotation).
+
+```json
+{
+  "keys": [
+    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "<sha-256 thumbprint>", "n": "...", "e": "AQAB" }
+  ]
+}
+```
+
+---
+
+## Client Registration endpoint
+
+`POST /knoxidf/api/v1/client/register` &nbsp; `Content-Type: application/x-www-form-urlencoded`
+
+Dynamically registers an OAuth2 client and returns a `client_id` and `client_secret`. Redirect
+URIs must use HTTPS (plain HTTP is allowed only for loopback hosts, per RFC 8252). Anonymous
+registration is **refused by default** and only permitted when
+`knoxidf.client.registration.anonymous.allowed=true` is set on the topology — see
+[Security](security.md#dynamic-client-registration).
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `redirect_uris` | Yes | Comma-separated. HTTPS required (loopback HTTP allowed); a wildcard `*` is only permitted at the end of the path, never in the host. |
+| `allowed_scopes` | No | Comma-separated; must include `openid`. Defaults to `openid,profile,email,offline_access`. |
+
+**Success (`200`):** returns `token_id` (the `client_id`), `passcode` (the `client_secret` to
+use on `/token`), and the stored `redirect_uris` and `allowed_scopes`.
+
+**Errors:** `access_denied` (anonymous caller when disabled), `invalid_request` (missing/invalid
+`redirect_uris`, wrong scheme), `invalid_scope` (`allowed_scopes` omits `openid`).
+
+---
+
+## Consent page
+
+`GET|POST /{topology}/authConsent`
+
+An HTML consent page (a lightweight servlet, registered only when the topology includes the
+`KNOXIDF` service). `GET` renders the requesting `client_id` and a human-readable description of
+each requested scope with **Accept** / **Deny** buttons. `POST` records the decision and
+redirects to `authorize/consentAccepted` (issues the code) or `authorize/consentDenied`
+(`403`). Consent is one-time per (user, client, scopes) — see
+[Security → Consent](security.md#consent).
+
+![The KnoxIDF consent page: "Application Consent Required", listing the requesting client and the scopes it will be granted, with Accept and Deny buttons.](../assets/images/knoxidf/consent_page.png)
+
+---
+
+## Trusted OIDC Issuers (admin)
+
+Base path `knoxidf/admin/v1/trusted-oidc-issuers`, served by the `KNOXIDF_ADMIN` service role
+(a separate, administrator-only topology). Manages the set of external issuers whose `id_token`s
+Knox will accept during federated login.
+
+| Method | Path | Purpose | Success |
+|--------|------|---------|---------|
+| `POST` | `/trusted-oidc-issuers` | Register a trusted issuer (JSON body: `issuerUrl` (HTTPS, required), `dynamicJwks`, `clusterName`). | `201` |
+| `GET` | `/trusted-oidc-issuers` | List registered issuers (with `registeredAt` / `registeredBy`). | `200` |
+| `DELETE` | `/trusted-oidc-issuers?issuerUrl=...` | Deregister an issuer (idempotent). | `204` |
+| `POST` | `/trusted-oidc-issuers/refresh-jwks?issuerUrl=...` | Force a JWKS cache refresh for a `dynamicJwks` issuer. | `204` |
+
+**Errors:** `400 invalid_request` (missing/non-HTTPS `issuerUrl`, malformed JSON),
+`409 issuer_exists`, `409 issuer_limit_reached` (issuer cap), `500 storage_error`.
diff --git a/knox-site/docs/knoxidf/federation.md b/knox-site/docs/knoxidf/federation.md
new file mode 100644
index 000000000..ad17d74c6
--- /dev/null
+++ b/knox-site/docs/knoxidf/federation.md
@@ -0,0 +1,221 @@
+<!--
+   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.
+-->
+
+# Federation
+
+In addition to being a standalone OIDC Provider, KnoxIDF can **broker** login to one or more
+external OpenID Providers (OPs) — Keycloak, Okta, Azure AD, Auth0, and so on. In this mode Knox
+delegates the actual authentication to the external OP, validates the identity it returns, and
+then re-issues **its own Knox-signed tokens** to the client. Downstream services still only need
+to trust Knox, regardless of where the user actually authenticated.
+
+Federation is entirely optional and configured per topology. A topology with no
+`federated.op.names` behaves as a pure Knox OP.
+
+## The login experience
+
+When a topology fronts `/authorize` with an SSO cookie provider and has one or more federated
+OPs enabled, the Knox login page offers the external OP as an alternative to Knox's own
+authentication providers (LDAP, PAM, SAML, Kerberos, …):
+
+![The Knox login page showing username/password fields and a "Continue with KeyCloak" button below an "Or" separator.](../assets/images/knoxidf/login_page_federated.png)
+
+The end user chooses whether to sign in with a Knox-native provider or to identify themselves
+through the external OIDC Provider.
+
+## Broker flow
+
+Federation is implemented as a token-brokering mechanism:
+
+```mermaid
+sequenceDiagram
+    autonumber
+    participant Client
+    participant Knox as Knox (KnoxIDF)
+    participant OP as External OP
+    Client->>Knox: GET /authorize (response_type=code, PKCE)
+    Note over Knox: Authenticate request, validate params, check consent
+    Knox->>OP: Redirect to OP /authorize (nonce, callback)
+    OP-->>Client: Prompt for login
+    Client->>OP: Authenticate
+    OP->>Knox: GET /authorize/callback (code, state)
+    Note over Knox: Exchange code at OP /token (back-channel)
+    Knox->>OP: POST /token (federated code, client_secret)
+    OP-->>Knox: OP tokens (id_token)
+    Note over Knox: Validate id_token (sig via JWKS, iss, aud, nonce, sub)
+    Note over Knox: Resolve / persist federated identity
+    Knox-->>Client: Redirect to redirect_uri (Knox code, state)
+    Client->>Knox: POST /token (code + client_secret / code_verifier)
+    Knox-->>Client: Knox access_token + id_token (+ refresh_token)
+```
+
+Step by step:
+
+1. **Client initiates.** The OIDC client calls `/authorize`. The topology's provider
+   authenticates the request and Knox validates the parameters and checks consent.
+2. **Delegate to the OP.** With a federated OP enabled, Knox builds an authorization redirect to
+   the OP's `authorize.endpoint`, including a freshly generated `nonce` (stored server-side for
+   this session) and the callback URL `/knoxidf/api/v1/authorize/callback`.
+3. **The OP authenticates the user** and redirects back to Knox's callback with an authorization
+   `code` and the `state`.
+4. **Back-channel token exchange.** Knox exchanges the OP code at the OP's `token.endpoint`,
+   resolving the OP `client_secret` (from an [alias](security.md#secret-handling) if configured).
+5. **Validate the OP `id_token`.** Knox verifies the signature (via the OP's JWKS), the issuer,
+   the audience, the `nonce`, and the presence of `sub` — see
+   [Security → Federated id_token validation](security.md#federated-id_token-validation). Nothing
+   in the token is trusted before this passes.
+6. **Resolve or persist the federated identity.** Knox looks up `(provider, issuer, subject)`;
+   if not found, it persists a new federated identity, deriving a stable Knox `sub` as a
+   [UUIDv5](security.md#subject-derivation) over the OP issuer and subject.
+7. **Issue a Knox authorization code**, then redirect the client to its `redirect_uri`.
+8. **Client redeems the code** at Knox's `/token` endpoint (with PKCE or `client_secret`) and
+   receives Knox-signed tokens whose `id_token` carries the federated claims below.
+
+## Federated claims in the Knox id_token
+
+For a federated user, the Knox-issued `id_token` (and the UserInfo response) carry the origin of
+the identity alongside the Knox subject:
+
+| Claim | Meaning | Example |
+|-------|---------|---------|
+| `sub` | Stable Knox subject (UUIDv5 over issuer + external subject). | `f47ac10b-58cc-45c8-...` |
+| `federated_idp` | The federated provider name, **upper-cased**. | `KEYCLOAK` |
+| `federated_sub` | The `sub` from the OP's id_token. | `248289761001` |
+| `federated_iss` | The `iss` from the OP's id_token. | `https://op.example/realms/knox` |
+
+Allowed profile claims (`preferred_username`, `email`, `email_verified`, `given_name`,
+`family_name`, `name`, `locale`) are included when present. The UserInfo response uses `idp` for
+the provider name (also upper-cased) in place of `federated_idp`.
+
+!!! note
+    Because `federated_idp` and the stored provider are upper-cased, a configured OP name of
+    `keycloak` appears in tokens as `KEYCLOAK`. Match on the upper-cased value in downstream
+    authorization logic.
+
+## Configuring a federated OP
+
+Federated OPs are declared as `KNOXIDF` service parameters so they are exposed as servlet
+context init-params (read by both `AuthorizeResource` and the SSO cookie federation filter in
+the same webapp). First list the OP logical names, then provide a block of
+`federated.op.<name>.*` parameters for each. The example below is the tested CI topology for a
+Keycloak OP:
+
+```xml
+<service>
+    <role>KNOXIDF</role>
+    <!-- ... core KnoxIDF params (ttl, consent, token-exchange topology) ... -->
+
+    <param>
+        <name>federated.op.names</name>
+        <value>keycloak</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.enabled</name>
+        <value>true</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.clientId</name>
+        <value>knox-client</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.clientSecret</name>
+        <value>knox-client-secret</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.authorize.endpoint</name>
+        <value>http://keycloak:8080/realms/knox/protocol/openid-connect/auth</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.authorize.callback</name>
+        <value>https://knox:8443/gateway/knoxidf-sso/knoxidf/api/v1/authorize/callback</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.token.endpoint</name>
+        <value>http://keycloak:8080/realms/knox/protocol/openid-connect/token</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.jwks.endpoint</name>
+        <value>http://keycloak:8080/realms/knox/protocol/openid-connect/certs</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.issuer</name>
+        <value>http://keycloak:8080/realms/knox</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.userinfo.endpoint</name>
+        <value>http://keycloak:8080/realms/knox/protocol/openid-connect/userinfo</value>
+    </param>
+    <param>
+        <name>federated.op.keycloak.signature.algorithm</name>
+        <value>RS256</value>
+    </param>
+</service>
+```
+
+!!! warning "Prefer an alias for the OP client secret"
+    The example above uses a plaintext `clientSecret` for brevity. In production, store the
+    secret in Knox's credential store and reference it with
+    `federated.op.keycloak.clientSecret.alias` instead — the alias takes precedence and
+    resolution fails closed if it cannot be resolved. See
+    [Security → Secret handling](security.md#secret-handling). See the
+    [Configuration Reference](configuration.md#federated-op-parameters) for every
+    `federated.op.<name>.*` parameter.
+
+### Front topology for federation
+
+The federation login experience requires a topology that fronts `/authorize` with an
+`SSOCookieProvider` (so an unauthenticated `/authorize` is redirected to the Knox login
+front-end), with the federation callback and other pre-login endpoints listed in
+`sso.unauthenticated.path.list`:
+
+```xml
+<provider>
+    <role>federation</role>
+    <name>SSOCookieProvider</name>
+    <enabled>true</enabled>
+    <param>
+        <name>sso.authentication.provider.url</name>
+        <value>https://knox:8443/gateway/knoxsso/api/v1/websso</value>
+    </param>
+    <param>
+        <name>sso.unauthenticated.path.list</name>
+        <value>/knoxidf/api/v1/authorize/callback;/knoxidf/api/v1/jwks;/knoxidf/api/v1/.well-known/openid-configuration;/knoxidf/api/v1/client/register</value>
+    </param>
+</provider>
+```
+
+## Multiple OPs
+
+`federated.op.names` accepts a comma-separated list, and each named OP gets its own
+`federated.op.<name>.*` block. Only OPs with `enabled=true` are activated; the login page offers
+each enabled OP as a separate sign-in option.
+
+## Trusted issuer registry
+
+The external issuers whose tokens Knox will accept are administered through the
+[Trusted OIDC Issuers admin API](endpoints.md#trusted-oidc-issuers-admin), served by the
+`KNOXIDF_ADMIN` role on an administrator-restricted topology. Issuer JWKS documents are cached
+(`gateway.trustedoidcissuer.discovery.cache.ttl.secs`, default 600s); the admin API's
+`refresh-jwks` action forces an immediate re-fetch for issuers configured with dynamic JWKS.
+
+## Persistence
+
+Federated identities are persisted so the same upstream user maps to a stable Knox subject and
+so their attributes can be reused. This store activates automatically when a `KNOXIDF` (or
+`KNOXIDF_ADMIN`) topology is present — no explicit configuration is required. See
+[Operations → Federated identity persistence](operations.md#federated-identity-persistence) for
+the backend-selection rules and how to point KnoxIDF at an external database.
diff --git a/knox-site/docs/knoxidf/getting_started.md b/knox-site/docs/knoxidf/getting_started.md
new file mode 100644
index 000000000..7b17ff19c
--- /dev/null
+++ b/knox-site/docs/knoxidf/getting_started.md
@@ -0,0 +1,284 @@
+<!--
+   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.
+-->
+
+# Getting Started
+
+This chapter walks through building Knox with KnoxIDF, deploying the topologies that expose
+the OIDC endpoints, registering a client, and running a first Client Credentials flow.
+
+!!! note "Branch"
+    At the time of writing, KnoxIDF lives on the `knox_idf` development branch (kept in sync
+    with `master`). Build from that branch until it is merged.
+
+## 1. Build Knox
+
+```bash
+git clone https://github.com/apache/knox.git
+cd knox
+git checkout knox_idf
+
+mvn -DskipTests -Dcheckstyle.skip=true -Dfindbugs.skip=true -Dpmd.skip=true \
+    -Drat.skip -Dspotbugs.skip=true -Dforbiddenapis.skip=true \
+    -Ppackage clean install
+```
+
+The build produces a Knox distribution archive under
+`gateway-release/target/{version}/knox-{version}.zip`.
+
+## 2. Install and start Knox
+
+Unzip the distribution into a deployment directory (`$KNOX_HOME`), create the master secret
+and the required aliases, then start the gateway. The signing-key hash alias
+(`knox.token.hash.key`) backs server-managed (passcode) tokens; the database aliases back the
+embedded/external persistence used by KnoxIDF and token state.
+
+```bash
+export KNOX_HOME=/path/to/knoxGateway
+
+# Master secret (non-interactive)
+$KNOX_HOME/bin/knoxcli.sh create-master --master gateway
+
+# Signing / passcode HMAC key
+$KNOX_HOME/bin/knoxcli.sh create-alias knox.token.hash.key --value <a-strong-random-secret>
+
+# Database credential aliases (used by the embedded Derby store and any external DB)
+$KNOX_HOME/bin/knoxcli.sh create-alias gateway_database_user --value knox
+$KNOX_HOME/bin/knoxcli.sh create-alias gateway_database_password --value knox
+
+$KNOX_HOME/bin/gateway.sh start
+```
+
+!!! tip "Local (non-TLS) testing"
+    For local experimentation you can disable TLS by setting `ssl.enabled=false` in
+    `$KNOX_HOME/conf/gateway-site.xml`. **Do not do this in production** — OAuth 2.0 / OIDC
+    requires TLS for all token-bearing traffic.
+
+By default, KnoxIDF's federated-identity persistence uses an **embedded Derby** database that
+Knox provisions automatically (the same physical DB used by token state). No external database
+is required to get started. To point KnoxIDF at an external database (PostgreSQL, etc.), see
+the [Configuration Reference](configuration.md) and [Operations](operations.md) chapters.
+
+## 3. Deploy the KnoxIDF topologies
+
+A typical KnoxIDF deployment uses **two topologies**:
+
+- **A "front" topology** that exposes the OIDC endpoints and authenticates the end user (for
+  example with LDAP Basic auth, or with an SSO cookie provider for federation). This is where
+  `/authorize`, `/client/register`, `/jwks`, and discovery live.
+- **A "token" topology** fronted by Knox's `JWTProvider`, referenced by the front topology's
+  `token.exchange.topology.name`. The `/token` exchange is redirected here so that redeeming an
+  authorization code is authenticated by a Knox-issued JWT.
+
+Copy the topology files into `$KNOX_HOME/conf/topologies/`; Knox hot-deploys them within a few
+seconds.
+
+### Front topology (LDAP Basic auth) — `knoxidf-ldap.xml`
+
+The `ShiroProvider` authenticates users against LDAP, and the OIDC endpoints that must be
+reachable *before* login (discovery, registration, JWKS, and the federation callback) are
+wired as `anon`:
+
+```xml
+<topology>
+    <gateway>
+      <provider>
+         <role>authentication</role>
+         <name>ShiroProvider</name>
+         <enabled>true</enabled>
+         <param>
+            <name>main.ldapRealm</name>
+            <value>org.apache.knox.gateway.shirorealm.KnoxLdapRealm</value>
+         </param>
+         <param>
+            <name>main.ldapRealm.userDnTemplate</name>
+            <value>uid={0},ou=people,dc=hadoop,dc=apache,dc=org</value>
+         </param>
+         <param>
+            <name>main.ldapRealm.contextFactory.url</name>
+            <value>ldaps://localhost:33390</value>
+         </param>
+         <param>
+            <name>main.ldapRealm.contextFactory.authenticationMechanism</name>
+            <value>simple</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/.well-known/openid-configuration</name>
+            <value>anon</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/client/register</name>
+            <value>anon</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/authorize/callback</name>
+            <value>anon</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/jwks</name>
+            <value>anon</value>
+          </param>
+         <param>
+            <name>urls./**</name>
+            <value>authcBasic</value>
+         </param>
+      </provider>
+      <provider>
+            <role>identity-assertion</role>
+            <name>Default</name>
+            <enabled>true</enabled>
+      </provider>
+    </gateway>
+
+    <service>
+        <role>KNOXIDF</role>
+        <param>
+            <name>knoxidf.knox.token.ttl</name>
+            <value>60000</value>
+        </param>
+        <param>
+            <name>knoxidf.knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+        <param>
+            <!-- Registration refuses anonymous callers unless this is explicitly true. -->
+            <name>knoxidf.client.registration.anonymous.allowed</name>
+            <value>true</value>
+        </param>
+        <param>
+            <!-- Skipping consent is a server-side deployment decision, never a client param. -->
+            <name>knoxidf.auto.consent.enabled</name>
+            <value>true</value>
+        </param>
+        <param>
+            <name>token.exchange.topology.name</name>
+            <value>knoxidf-token</value>
+        </param>
+    </service>
+</topology>
+```
+
+!!! warning "Anonymous client registration is opt-in"
+    `knoxidf.client.registration.anonymous.allowed` defaults to **`false`** (secure by
+    default). The sample above sets it to `true` only to keep the endpoint open for
+    experimentation. See [Security](security.md#dynamic-client-registration).
+
+### Token topology (`JWTProvider`) — `knoxidf-token.xml`
+
+```xml
+<topology>
+    <gateway>
+      <provider>
+         <role>federation</role>
+         <name>JWTProvider</name>
+         <enabled>true</enabled>
+         <param>
+            <name>knox.token.exp.server-managed</name>
+            <value>true</value>
+         </param>
+      </provider>
+      <provider>
+            <role>identity-assertion</role>
+            <name>Default</name>
+            <enabled>true</enabled>
+      </provider>
+    </gateway>
+
+    <service>
+        <role>KNOXIDF</role>
+        <param>
+           <name>knoxidf.knox.token.ttl</name>
+           <value>86400000</value>
+        </param>
+        <param>
+            <name>knoxidf.knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+        <param>
+            <name>knoxidf.auto.consent.enabled</name>
+            <value>true</value>
+        </param>
+    </service>
+    <service>
+        <role>KNOXTOKEN</role>
+        <param>
+            <name>knox.token.ttl</name>
+            <value>60000</value>
+        </param>
+        <param>
+            <name>knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+    </service>
+</topology>
+```
+
+## 4. Discover the endpoints
+
+Every subsequent step should read endpoint URLs from the discovery document rather than
+hard-coding paths. Fetch it from the front topology:
+
+```bash
+curl -sk https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/.well-known/openid-configuration | jq .
+```
+
+The response includes `issuer`, `authorization_endpoint`, `token_endpoint`,
+`userinfo_endpoint`, `jwks_uri`, `registration_endpoint`, and the supported grant types,
+scopes, response types, and PKCE methods. See the [Endpoint Reference](endpoints.md) for the
+full document.
+
+## 5. Register a client
+
+```bash
+curl -sk -X POST \
+  https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/client/register \
+  -H 'Content-Type: application/json' \
+  -d '{
+        "client_name": "my-first-client",
+        "redirect_uris": ["https://app.example.com/callback"],
+        "grant_types": ["authorization_code", "refresh_token"]
+      }' | jq .
+```
+
+The response contains a generated `client_id` and, for confidential clients, a
+`client_secret`. Store the secret securely — it is required to redeem authorization codes on
+the token endpoint (see [Security](security.md#token-endpoint-client-authentication)).
+
+## 6. Run a Client Credentials flow
+
+The Client Credentials grant issues a token to a confidential client with no interactive user
+login. Post the client credentials to the token endpoint advertised by discovery:
+
+```bash
+curl -sk -X POST \
+  https://knox:8443/gateway/knoxidf-ldap/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>' \
+  -d 'scope=openid' | jq .
+```
+
+You will receive a Knox-signed access token (and, when `openid` is requested, an ID token).
+Verify it against the JWKS endpoint (`jwks_uri`).
+
+## Next steps
+
+- To drive an interactive login, use the **Authorization Code + PKCE** flow — see the
+  [Endpoint Reference](endpoints.md#authorization-endpoint) and [Security](security.md#pkce).
+- To broker login to an external OIDC Provider (Keycloak, Okta, Azure AD, Auth0), see
+  **[Federation](federation.md)**.
+- To tune tokens, persistence, and claims, see the **[Configuration Reference](configuration.md)**.
diff --git a/knox-site/docs/knoxidf/index.md b/knox-site/docs/knoxidf/index.md
new file mode 100644
index 000000000..3eac8e1b5
--- /dev/null
+++ b/knox-site/docs/knoxidf/index.md
@@ -0,0 +1,92 @@
+<!--
+   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.
+-->
+
+# Identity Federation (OIDC Provider)
+
+## Overview
+
+Historically, Apache Knox Gateway has acted as a *federation client* — it delegates
+authentication to external Identity Providers (IdPs) such as CAS, SAML, OAuth 2.0, and
+OpenID Connect (OIDC) providers (via pac4j). In that model Knox is strictly a relying
+party and never an identity provider itself.
+
+**KnoxIDF** turns Apache Knox into an **OAuth 2.0 / OpenID Connect Provider (OP)** in its
+own right, while retaining Knox's existing federation capabilities. With KnoxIDF:
+
+- Knox can **issue** OAuth 2.0 / OIDC tokens (access tokens and ID tokens) directly to clients.
+- Knox can optionally **federate** identities and tokens from external, well-known OIDC
+  Providers (e.g. Keycloak, Okta, Azure AD, Auth0), brokering the login and then re-issuing
+  its own Knox-signed tokens.
+- Downstream services integrate with Knox exactly as they would with any standard OIDC
+  provider — they only need to trust Knox, regardless of how authentication was performed
+  upstream.
+
+This makes Knox both an **OIDC Provider** and an **OIDC federation bridge**, enabling gradual
+migration to — or a hybrid of — Knox-centric and external identity architectures.
+
+![KnoxIDF architecture: a client obtains OIDC tokens from Knox; Knox can optionally federate authentication to external OIDC providers and issues its own tokens to downstream services.](../assets/images/knoxidf/architecture.png)
+
+## Why KnoxIDF
+
+Many modern architectures expect a centralized OIDC Provider that issues tokens to
+downstream services. Products like Okta, Azure AD, and Keycloak are commonly used for this,
+but introducing and operating a separate IdP is not always desirable — especially in
+Hadoop-centric or Knox-centric deployments where Knox is already the trusted edge. KnoxIDF
+closes that gap: the gateway you already run at the perimeter becomes the token authority for
+the services behind it.
+
+## Capabilities
+
+KnoxIDF is implemented as a new Knox service (role `KNOXIDF`) that can be attached to any
+topology. It provides:
+
+| Capability | Description |
+|------------|-------------|
+| Standard OIDC endpoints | Discovery (`.well-known/openid-configuration`), authorization, token, userinfo, JWKS, and dynamic client registration. |
+| Client Credentials flow | Machine-to-machine token issuance. |
+| Authorization Code flow + PKCE | Interactive user login with PKCE (S256) for public clients and `client_secret` for confidential clients. |
+| Refresh tokens | Refresh-token grant with rotation. |
+| Consent | A one-time-per-(user, client) consent screen for the Authorization Code flow. |
+| Federation (optional) | Broker login to one or more external OIDC Providers and re-issue Knox tokens. |
+| Attribute enrichment | Hard-coded ID-token claims and pluggable user-parameter providers (e.g. LDAP attributes). |
+| Persistence | Federated identity data persisted for traceability and attribute reuse (ID-token data only — no access/refresh tokens or secrets). |
+
+## How it fits into Knox
+
+KnoxIDF operates independently from pac4j-based inbound authentication and does not change
+existing gateway authentication flows. A topology that includes the `KNOXIDF` service exposes
+the OIDC endpoints; the topology's own authentication/federation providers (Shiro/LDAP,
+SSOCookie, JWT, etc.) still govern how the caller is authenticated before KnoxIDF issues a
+token. This keeps KnoxIDF modular and composable with the rest of Knox.
+
+## Where to go next
+
+- **[Getting Started](getting_started.md)** — build, deploy, register a client, and run your first flow.
+- **[Endpoint Reference](endpoints.md)** — every REST endpoint KnoxIDF exposes.
+- **[Configuration Reference](configuration.md)** — every configuration parameter.
+- **[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.
+
+!!! note "Relationship to KIP-18"
+    KnoxIDF was originally proposed and prototyped in
+    [KIP-18 — Knox as OIDC Provider](https://cwiki.apache.org/confluence/spaces/KNOX/pages/406618787/KIP-18+-+Knox+as+OIDC+Provider).
+    KIP-18 describes the original design and proof-of-concept. The implementation has since
+    evolved (for example, refresh-token support, hardened client authentication, and
+    automatic federated-identity persistence were added after the initial proposal). Where the
+    KIP and this documentation differ, **this documentation reflects the current code and is
+    authoritative**; KIP-18 remains useful background on the motivation and design.
diff --git a/knox-site/docs/knoxidf/operations.md b/knox-site/docs/knoxidf/operations.md
new file mode 100644
index 000000000..ddd2ab210
--- /dev/null
+++ b/knox-site/docs/knoxidf/operations.md
@@ -0,0 +1,182 @@
+<!--
+   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.
+-->
+
+# Operations
+
+This chapter covers running KnoxIDF in production: where identity state is persisted, how to
+rotate signing keys without disrupting clients, how requests are audited, and how KnoxIDF behaves
+behind a highly available Knox deployment.
+
+## Federated identity persistence
+
+KnoxIDF persists federated-identity data so that the same upstream user maps to a stable Knox
+subject across logins and restarts, and so a filtered set of profile attributes can be reused. It
+stores **only ID-token–derived data** — no access tokens, refresh tokens, or OP client secrets
+(see [Security → What is (and isn't) stored at rest](security.md#what-is-and-isnt-stored-at-rest)).
+
+### Backend selection
+
+The persistence backend activates automatically whenever a topology with the `KNOXIDF` or
+`KNOXIDF_ADMIN` role is deployed. Which backend is used follows the gateway's database
+configuration:
+
+| `gateway.database.type` | Backend | Notes |
+|-------------------------|---------|-------|
+| `none` (default) or `derbydb` | Self-provisioning **embedded Derby** | Uses the same physical embedded database as token state (under the gateway security directory). Zero setup. |
+| A real external type (`postgresql`, `mysql`, `oracle`, …) | **JDBC-backed** store | Uses the operator-configured external database. Recommended for HA. |
+
+You can also pin the implementation explicitly with the service property
+`gateway.service.KnoxIDFFederatedIdentityService.impl` (Empty / Derby / JDBC); an explicit value
+always wins over auto-selection. Setting it to the empty (no-op) implementation disables
+persistence.
+
+!!! note "Use an external database for multi-instance deployments"
+    The embedded Derby store is local to a single gateway process. For a clustered / HA
+    deployment where more than one Knox instance must share federated-identity state, configure an
+    external database (see [Configuration → Persistence](configuration.md#persistence-and-database)).
+
+### What is stored
+
+- **Identity mapping:** Knox subject (UUIDv5), provider name (upper-cased), external subject,
+  external issuer.
+- **Attributes:** the allow-listed profile claims (`preferred_username`, `email`, `email_verified`,
+  `given_name`, `family_name`, `name`, `locale`).
+
+The identity row and its attributes are written in a **single transaction**, and a unique
+constraint on `(provider, external_issuer, external_subject)` makes concurrent first-logins of the
+same user converge on one row.
+
+## Signing-key rotation
+
+KnoxIDF signs issued JWTs with the gateway signing key (`gateway.signing.key.alias`, default
+`gateway-identity`) and publishes the corresponding public key(s) on the
+[JWKS endpoint](endpoints.md#jwks-endpoint). Each published key is identified by a `kid` equal to
+the **SHA-256 thumbprint** of its public key, so verifiers select the right key by `kid` rather
+than assuming a single static key.
+
+To rotate the signing key **without breaking tokens already in the wild**:
+
+1. **Provision the new key** in the signing keystore under a new alias.
+2. **Publish both keys.** Add the *old* alias to `gateway.signing.key.aliases.additional` in
+   `gateway-site.xml` so the JWKS endpoint serves both the old and new public keys:
+
+   ```xml
+   <property>
+       <name>gateway.signing.key.aliases.additional</name>
+       <value>gateway-identity-previous</value>
+   </property>
+   ```
+
+3. **Cut over signing** by pointing `gateway.signing.key.alias` at the new alias. New tokens are
+   now signed with the new key; verifiers still find the old key (by its `kid`) on JWKS for
+   tokens signed before the cutover.
+4. **Retire the old key** once all tokens signed with it have expired: remove it from
+   `gateway.signing.key.aliases.additional`.
+
+Because clients resolve keys from JWKS by `kid`, no client reconfiguration is needed at any step.
+
+!!! tip "Order matters"
+    Publish the new key on JWKS *before* you start signing with it, and keep the old key published
+    *until* the last token it signed has expired. Overlapping the two windows is what makes the
+    rotation seamless.
+
+## Auditing
+
+KnoxIDF actions are recorded through Knox's standard audit framework, so KnoxIDF audit records
+appear in the same audit log as the rest of the gateway (`$KNOX_HOME/logs/gateway-audit.log` by
+default) and follow the gateway's configured audit layout. Security-relevant operations — token
+issuance, client registration, consent decisions, federated login, and trusted-issuer
+administration — are audited with the acting principal and outcome.
+
+Audit output is configured through the gateway's Log4j2 configuration
+(`$KNOX_HOME/conf/gateway-log4j2.xml`), the same as every other Knox audit stream; see
+[Audit](../config_audit.md) for audit-appender and retention configuration.
+
+## High availability
+
+KnoxIDF adds no HA mechanism of its own — it inherits Knox's standard HA model. Run multiple Knox
+instances behind a load balancer as you would for any other Knox service, with two
+KnoxIDF-specific requirements:
+
+- **Shared persistence.** All instances must point at the **same external database**
+  (`gateway.database.*`) so a federated identity created on one instance is visible on the others.
+  The embedded Derby default is per-process and is not suitable for multi-instance HA.
+- **Consistent signing keys.** All instances must share the same signing keystore and the same
+  `gateway.signing.key.alias` / `gateway.signing.key.aliases.additional` configuration, so a token
+  issued by one instance verifies against the JWKS served by any instance.
+
+Sticky sessions are recommended for the interactive Authorization Code / federation flow so that
+the browser stays on the instance holding the in-flight authorize/consent state, though the issued
+tokens themselves are verifiable on any instance.
+
+## Rate limiting
+
+KnoxIDF does not implement rate limiting of its own, but it does not need to — Knox's
+**`WebAppSec` provider** ships a rate-limiting filter you can attach to a KnoxIDF topology to
+throttle request flooding, whether malicious or from a misconfigured client. This protects the
+high-value token, authorization, and registration endpoints without any external infrastructure.
+
+Add the provider to the KnoxIDF topology and enable rate limiting:
+
+```xml
+<provider>
+    <role>webappsec</role>
+    <name>WebAppSec</name>
+    <enabled>true</enabled>
+    <param>
+        <name>rate.limiting.enabled</name>
+        <value>true</value>
+    </param>
+    <param>
+        <name>rate.limiting.maxRequestsPerSec</name>
+        <value>25</value>
+    </param>
+    <param>
+        <!-- -1 = reject over-limit requests; a non-negative value delays instead
+             and requires gateway.servlet.async.supported=true in gateway-site.xml -->
+        <name>rate.limiting.delayMs</name>
+        <value>-1</value>
+    </param>
+</provider>
+```
+
+The filter tracks request rate per connection (or per session when
+`rate.limiting.trackSessions=true`), delays or rejects requests over
+`rate.limiting.maxRequestsPerSec`, and can exempt trusted callers via
+`rate.limiting.ipWhitelist`. See the
+[WebAppSec provider → Rate limiting](../config_webappsec_provider.md) documentation for the full
+parameter set (`delayMs`, `maxWaitMs`, `throttledRequests`, `insertHeaders`, `ipWhitelist`, …).
+
+!!! note "Async support for non-rejecting modes"
+    A non-negative `rate.limiting.delayMs` (delay rather than reject) requires
+    `gateway.servlet.async.supported=true` in `gateway-site.xml` (it is `false` by default).
+
+You may still add rate limiting at the edge (load balancer / reverse proxy) as defense in depth.
+Anonymous client registration in particular should either be left disabled (the default) or, when
+enabled, fronted by the rate-limiting filter above.
+
+## Operational checklist
+
+- [ ] TLS enabled on every topology that exposes KnoxIDF endpoints.
+- [ ] External database configured for any multi-instance / HA deployment.
+- [ ] Signing keystore and `gateway.signing.key.alias*` identical across all instances.
+- [ ] `knoxidf.client.registration.anonymous.allowed` reviewed (default `false`).
+- [ ] `knoxidf.auto.consent.enabled` reviewed (default `false`).
+- [ ] Federated OP client secrets stored as aliases, not plaintext.
+- [ ] Trusted-issuer registry (`KNOXIDF_ADMIN`) exposed only on an administrator-restricted topology.
+- [ ] Rate limiting enabled (WebAppSec provider on the topology, and/or at the edge) for the token / authorize / registration endpoints.
+- [ ] Audit log retention configured.
diff --git a/knox-site/docs/knoxidf/security.md b/knox-site/docs/knoxidf/security.md
new file mode 100644
index 000000000..47ded7046
--- /dev/null
+++ b/knox-site/docs/knoxidf/security.md
@@ -0,0 +1,183 @@
+<!--
+   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.
+-->
+
+# Security
+
+This chapter describes the security controls KnoxIDF enforces as an OAuth 2.0 / OIDC provider.
+Understanding them is important when hardening a deployment and when reasoning about the trust
+boundary between clients, Knox, and any external OIDC Providers.
+
+!!! warning "Always run over TLS"
+    OAuth 2.0 and OIDC assume a confidential channel. Every token-bearing endpoint must be
+    served over HTTPS in production. The `ssl.enabled=false` option is for local development
+    only.
+
+## Token-endpoint client authentication
+
+The token endpoint independently authenticates the client when redeeming an authorization code —
+it does not rely on the upstream `JWTProvider` (which authenticates the *request* but does not
+verify the OAuth `client_secret`). The check is chosen by what was stored at authorize time:
+
+- **PKCE path** — if the authorization request included a `code_challenge`, the client must
+  present a matching `code_verifier`. See [PKCE](#pkce).
+- **Client-secret path** — if no `code_challenge` was stored, the client must present a valid
+  `client_secret`.
+
+The two paths are mutually exclusive, and there is no path where neither applies. The
+`refresh_token` grant always requires a `client_secret` (there is no PKCE bypass for refresh).
+
+Client-secret verification is **constant-time**. The secret on the wire encodes the token id and
+a passcode; Knox recomputes an HMAC (keyed by the `knox.token.hash.key` alias, over the token id,
+issue time, and user name as a per-token salt) and compares it to the stored value with a
+constant-time comparison. The embedded token id must equal the request `client_id`, binding the
+secret to the specific client.
+
+## PKCE
+
+Only the **S256** code-challenge method is accepted. `plain` (and an omitted method, which OAuth
+would otherwise default to `plain`) is **rejected** — both at the authorization endpoint (which
+refuses to store a non-S256 challenge) and at the token endpoint (which refuses to verify with
+any other method). This is enforced as defense in depth at both ends of the flow. The S256
+challenge is computed as `BASE64URL(SHA-256(ASCII(code_verifier)))` without padding, per
+RFC 7636.
+
+Public clients (no `client_secret`) must use PKCE; this is how a public client proves possession
+of the authorization code at redemption time.
+
+## Single-use authorization codes
+
+Authorization codes are single-use. The code is **atomically consumed before any token is
+issued**, so of any number of concurrent redemptions of the same code, exactly one succeeds and
+the rest receive `invalid_grant`. This closes the check-then-issue replay window.
+
+A code that fails *validation* (bad `redirect_uri`, wrong `client_id`, PKCE/secret failure) is
+deliberately **not** consumed — this prevents a denial-of-service in which an attacker replays a
+victim's code with bad parameters to burn it before the legitimate client redeems it.
+
+## Redirect-URI validation
+
+Open redirects are prevented both at registration and at authorization time.
+
+**At registration** (`/client/register`):
+
+- The **host** component may not contain a wildcard.
+- **HTTPS is required.** Plain `http://` is accepted only for loopback hosts (`localhost`,
+  `127.0.0.1`, `::1`), per RFC 8252 for native apps.
+- A wildcard `*` is permitted only at the **end of the path**, never in the host, query, or
+  fragment.
+
+**At authorization** (`/authorize`):
+
+- Non-wildcard URIs are matched by exact string equality.
+- Wildcard URIs are matched by first comparing the **origin** (scheme + host + port) so that a
+  registered `https://good.example*` cannot match `https://good.example.evil.com`. Only then is
+  the path prefix compared, after both paths are `URI.normalize()`d — collapsing traversal
+  segments (e.g. `/callback/../admin`) so a raw prefix match cannot be tricked into escaping the
+  registered prefix.
+
+## Consent
+
+For the Authorization Code flow, KnoxIDF presents a [consent page](endpoints.md#consent-page)
+where the user approves the scopes a client is requesting. Consent is **one-time per (user,
+client, scopes)**: once granted, subsequent authorization requests for the same scopes proceed
+without re-prompting.
+
+Whether consent can be skipped is a **server-side deployment decision**, governed by the
+topology parameter `knoxidf.auto.consent.enabled`. It is read from the topology configuration at
+startup and is **never** read from the incoming HTTP request — a client cannot bypass the consent
+screen by sending an `auto_consent=true` parameter.
+
+Consent records are stored as metadata on the client's token record, under a fixed-width key
+derived as `"consent_"` + the first 20 hex characters of `SHA-256(subject)` (28 characters
+total). Hashing the subject keeps the key within the storage column width regardless of how long
+the username or federated UUID subject is, and the read and write paths derive the key
+identically so they always agree.
+
+## Dynamic client registration
+
+Dynamic client registration is a deliberately supported deployment mode, but it is **not open by
+default**. The endpoint refuses anonymous callers unless the topology explicitly sets:
+
+```xml
+<param>
+    <name>knoxidf.client.registration.anonymous.allowed</name>
+    <value>true</value>
+</param>
+```
+
+The default is **`false`** (secure by default). When open registration is enabled, the
+token-endpoint client authentication described above is what still prevents a
+registered-but-unauthenticated client from redeeming another client's authorization code.
+
+## Federated id_token validation
+
+When Knox brokers login to an external OIDC Provider, the OP's `id_token` is **fully validated
+before any claim is trusted** — it is never decoded and trusted as-is. Validation fails closed at
+each stage:
+
+1. **Signature + `exp`/`nbf`** — verified against the OP's JWKS (fetched from the configured
+   `jwks.endpoint`) using the configured signature algorithm (default `RS256`).
+2. **Issuer** — must equal the statically configured `federated.op.<name>.issuer`, not a value
+   read from the token itself.
+3. **Audience** — must contain the configured `federated.op.<name>.clientId` (Knox's client id
+   at the OP).
+4. **Subject** — `sub` must be present and non-blank.
+5. **Nonce** — the token's `nonce` must equal the nonce Knox generated for that login session,
+   binding the token to the specific authorization request.
+
+If the OP configuration is missing its `jwks.endpoint`, `issuer`, or `clientId`, validation is
+refused outright — no OP token can be accepted.
+
+See [Federation](federation.md) for the full broker flow.
+
+## Trusted issuer registry
+
+The set of external issuers Knox will accept `id_token`s from is administered through the
+[Trusted OIDC Issuers admin API](endpoints.md#trusted-oidc-issuers-admin) (`KNOXIDF_ADMIN`
+role), which should be exposed only on an administrator-restricted topology. Registered issuer
+URLs must be HTTPS, and there is a configurable upper bound on the number of trusted issuers.
+
+## Secret handling
+
+- **Federated OP client secrets** can be resolved from Knox's `AliasService` rather than being
+  written in plaintext in the topology. Set `federated.op.<name>.clientSecret.alias` to an alias
+  name; it takes precedence over the plaintext `clientSecret` parameter. If an alias is
+  configured but cannot be resolved, resolution **fails closed** — the request to the OP is not
+  made, rather than silently falling back to a plaintext value.
+- **Federated access tokens are never persisted.** Only the federated *identity* (ID-token–derived
+  data) is stored; the OP's access token is discarded immediately after the token exchange.
+  Persisting an OP bearer token in plaintext token metadata would be a secret-at-rest exposure.
+
+## Subject derivation
+
+For federated users, the Knox `sub` is a deterministic **UUIDv5** over a fixed namespace UUID
+(`6ba7b811-9dad-11d1-80b4-00c04fd430c8`, the RFC 4122 "URL" namespace) with the name
+`issuer + "|" + subject`. The same upstream user therefore always maps to the same Knox subject
+across logins and gateway restarts.
+
+!!! danger "Do not change the subject namespace"
+    Because the `sub` is derived from a fixed namespace UUID, changing that namespace would
+    rewrite the subject of **every** previously persisted federated user. The namespace is an
+    immutable part of the deployment's identity contract.
+
+## What is (and isn't) stored at rest
+
+KnoxIDF persists **only ID-token–derived federated identity data** — the core identity mapping
+(Knox subject, provider, external subject, external issuer) and a filtered set of profile
+attributes (`preferred_username`, `email`, `email_verified`, `given_name`, `family_name`,
+`name`, `locale`). It does **not** store access tokens, refresh tokens, or OP client secrets.
+This keeps the persisted footprint to what is needed for traceability and attribute reuse.
diff --git a/knox-site/mkdocs.yml b/knox-site/mkdocs.yml
index f147f0937..c0f91bdbb 100644
--- a/knox-site/mkdocs.yml
+++ b/knox-site/mkdocs.yml
@@ -114,6 +114,14 @@ nav:
           - General Troubleshooting: admin_troubleshooting.md
           - Authentication Issues: auth_troubleshooting.md
           - Service-Specific Issues: service_troubleshooting.md
+  - Identity Federation (OIDC Provider):
+      - Overview: knoxidf/index.md
+      - Getting Started: knoxidf/getting_started.md
+      - Endpoint Reference: knoxidf/endpoints.md
+      - Configuration Reference: knoxidf/configuration.md
+      - Security: knoxidf/security.md
+      - Federation: knoxidf/federation.md
+      - Operations: knoxidf/operations.md
   - Developer Guide:
       - Overview: dev-guide/book.md
       - Extending Knox:
@@ -134,7 +142,11 @@ markdown_extensions:
       permalink: true
   - admonition
   - pymdownx.details
-  - pymdownx.superfences
+  - pymdownx.superfences:
+      custom_fences:
+        - name: mermaid
+          class: mermaid
+          format: !!python/name:pymdownx.superfences.fence_code_format
   - pymdownx.highlight:
       anchor_linenums: true
   - pymdownx.inlinehilite


(knox) 15/37: KNOX-3414: security hardening for the OIDC provider (Tier 1 + Tier 2)

Posted by sm...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

smolnar82 pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git

commit d9b1453d2895cd2fe75a1c60093b4935de4f495e
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Mon Aug 10 20:28:14 2026 +0200

    KNOX-3414: security hardening for the OIDC provider (Tier 1 + Tier 2)
    
    Fixes a batch of security and correctness findings from reviewing the
    squashed "Knox as OIDC Provider" feature. Reviewed and tested together.
    
    Tier 1 (critical):
    - 1.1 Authenticate the client on the auth-code token exchange. The
      JWTFederationFilter Bearer path forwards to the token endpoint without
      checking client_secret, so a stolen code could be redeemed by anyone
      holding some valid Knox JWT. The endpoint now independently binds
      redemption to the client: PKCE code_verifier (S256) when a challenge was
      stored, else a constant-time client_secret check.
    - 1.2 Validate the federated id_token (signature via the OP JWKS, expected
      issuer, audience, exp/nbf) before trusting any claim; fail closed when
      jwks.endpoint/issuer are not configured.
    - 1.3 Add knoxidf.client.registration.anonymous.allowed (default false):
      dynamic client registration refuses anonymous callers unless explicitly
      enabled. Sample knoxidf-ldap topology opts in to preserve open reg.
    - 1.4 Stop leaking custom claims across users: build a per-request copy of
      the claim map instead of mutating the shared singleton field.
    
    Tier 2 (high):
    - 2.1 Default issueTime to now in JWTokenAttributesBuilder so every token
      gets a correct iat (fixes iat=1970 on KnoxSSO cookies/assertions).
    - 2.2 Set SCOPE_ATTRIBUTE to the scope value (was a double getClaim -> null).
    - 2.3 Match redirect_uri on parsed URI components with a path/host boundary
      (fixes wildcard open-redirect via startsWith).
    - 2.5 Require S256 PKCE; reject plain.
    - 2.6 Use the Knox truststore for federated OP HTTP calls.
    - 2.7 Treat auto_consent as a server-side policy, not a client bypass.
    - 2.8 Escape username in LDAP DN/filter (Rdn.escapeValue).
    - 2.9 Require and validate state on the authorize flow.
    - 2.10 Null-guard the federated callback / registration and return 4xx.
    
    Tests: TokenResourceClientAuthTest, RegistrationResourceAnonymousGuardTest,
    FederatedOpConfigurationTest, JWTTokenTest iat assertion.
    
    Deferred to follow-up PRs: 1.5 (secrets at rest via AliasService),
    2.4 + 2.11 (atomic single-use auth-code consume + schema migration across
    dialects; the auth-code replay window remains until 2.4 lands), Tier 3/4.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../build/conf/topologies/knoxidf-ldap.xml         |   7 +
 .../federation/jwt/filter/JWTFederationFilter.java |   2 +-
 .../gateway/service/knoxidf/AuthorizeResource.java | 151 +++++++++++++++++++--
 .../service/knoxidf/RegistrationResource.java      |  32 +++++
 .../gateway/service/knoxidf/TokenResource.java     |  87 ++++++++++--
 .../knoxidf/userparams/LdapUserParamsProvider.java |   6 +-
 .../RegistrationResourceAnonymousGuardTest.java    |  78 +++++++++++
 .../knoxidf/TokenResourceClientAuthTest.java       | 116 ++++++++++++++++
 .../gateway/service/knoxtoken/TokenResource.java   |  10 +-
 .../security/token/JWTokenAttributesBuilder.java   |   5 +-
 .../services/security/token/impl/JWTTokenTest.java |  28 ++++
 .../util/knoxidf/AuthorizeRequestMetadata.java     |   7 +
 .../util/knoxidf/FederatedOpConfiguration.java     |  24 ++++
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |   5 +
 .../util/knoxidf/FederatedOpConfigurationTest.java |  74 ++++++++++
 15 files changed, 603 insertions(+), 29 deletions(-)

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


(knox) 03/37: KNOX-3355 - Add TrustedOidcIssuerService schema and interface (#1311)

Posted by sm...@apache.org.
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 e0aab56b5c2030768cb757f4e8468a67bcbe6ba9
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Tue Jul 21 13:31:28 2026 -0700

    KNOX-3355 - Add TrustedOidcIssuerService schema and interface (#1311)
    
    * KNOX-3355 - Add TrustedOidcIssuerService schema and interface
    Co-authored-by: Harrison <hs...@cloudera.com>
---
 .../database/AbstractDataSourceFactory.java        |   4 +
 .../src/main/resources/conf/gateway-site.xml       |  49 +++++++++
 .../createKnoxIDFTrustedOidcIssuersTable.sql       |  23 ++++
 .../createKnoxIDFTrustedOidcIssuersTableDerby.sql  |  22 ++++
 .../createKnoxIDFTrustedOidcIssuersTableOracle.sql |  23 ++++
 .../services/AbstractGatewayServicesTest.java      |   3 +-
 .../trustedoidcissuer/TrustedOidcIssuerTest.java   |  71 ++++++++++++
 .../TrustedOidcIssuersSchemaTest.java              | 121 +++++++++++++++++++++
 .../apache/knox/gateway/services/ServiceType.java  |   3 +-
 .../trustedoidcissuer/TrustedOidcIssuer.java       |  63 +++++++++++
 .../TrustedOidcIssuerService.java                  |  96 ++++++++++++++++
 11 files changed, 476 insertions(+), 2 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java
index a9d544fe8..a76b219a9 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java
@@ -50,6 +50,10 @@ public abstract class AbstractDataSourceFactory {
     public static final String DERBY_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityTableDerby.sql";
     public static final String DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityAttributesTableDerby.sql";
 
+    public static final String KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL = "createKnoxIDFTrustedOidcIssuersTable.sql";
+    public static final String DERBY_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL = "createKnoxIDFTrustedOidcIssuersTableDerby.sql";
+    public static final String ORACLE_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL = "createKnoxIDFTrustedOidcIssuersTableOracle.sql";
+
     public static final String DATABASE_USER_ALIAS_NAME = "gateway_database_user";
     public static final String DATABASE_PASSWORD_ALIAS_NAME = "gateway_database_password";
     public static final String DATABASE_TRUSTSTORE_PASSWORD_ALIAS_NAME = "gateway_database_ssl_truststore_password";
diff --git a/gateway-server/src/main/resources/conf/gateway-site.xml b/gateway-server/src/main/resources/conf/gateway-site.xml
index f549c413f..a44a81d7e 100644
--- a/gateway-server/src/main/resources/conf/gateway-site.xml
+++ b/gateway-server/src/main/resources/conf/gateway-site.xml
@@ -125,4 +125,53 @@ limitations under the License.
         <description>Interceptor type.</description>
     </property>
 
+    <!-- KnoxIDF Trusted OIDC Issuer Service Configuration
+         These properties configure the TrustedOidcIssuerService gateway service,
+         which manages the registry of OIDC issuers trusted for dynamic JWKS
+         discovery. They are gateway-scoped and apply to all topologies that
+         deploy the KNOXIDF or KNOXIDF_ADMIN service roles.
+    -->
+    <!--
+    <property>
+        <name>gateway.trustedoidcissuer.max.issuers</name>
+        <value>10000</value>
+        <description>
+            Maximum number of OIDC issuers that may be registered in the trusted
+            issuer registry. Increasing this value may delay how quickly updates
+            take effect.
+        </description>
+    </property>
+
+    <property>
+        <name>gateway.trustedoidcissuer.discovery.cache.ttl.secs</name>
+        <value>600</value>
+        <description>
+            TTL in seconds for the resolved JWKS URI per trusted issuer. After
+            expiry the JWKS URI is re-resolved on the next use. Use the admin
+            API refresh endpoint to force immediate re-resolution without waiting
+            for TTL expiry.
+        </description>
+    </property>
+
+    <property>
+        <name>gateway.trustedoidcissuer.discovery.connect.timeout.ms</name>
+        <value>3000</value>
+        <description>
+            HTTP connect timeout in milliseconds when fetching an OIDC discovery
+            document (/.well-known/openid-configuration). A short value fails fast
+            if the issuer endpoint is unreachable.
+        </description>
+    </property>
+
+    <property>
+        <name>gateway.trustedoidcissuer.discovery.read.timeout.ms</name>
+        <value>10000</value>
+        <description>
+            HTTP read timeout in milliseconds when fetching an OIDC discovery
+            document. Allows for slower issuer endpoints while bounding the
+            latency of a cache-miss resolution.
+        </description>
+    </property>
+    -->
+
 </configuration>
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTable.sql b/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTable.sql
new file mode 100644
index 000000000..a97a4a6cc
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTable.sql
@@ -0,0 +1,23 @@
+--  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.
+
+CREATE TABLE IF NOT EXISTS TRUSTED_OIDC_ISSUERS (
+    issuer_url    VARCHAR(2048) NOT NULL,
+    dynamic_jwks  BOOLEAN       DEFAULT false NOT NULL,
+    registered_at TIMESTAMP     NOT NULL,
+    registered_by VARCHAR(2048),
+    cluster_name  VARCHAR(256),
+    PRIMARY KEY (issuer_url)
+);
diff --git a/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableDerby.sql
new file mode 100644
index 000000000..a3e77b465
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableDerby.sql
@@ -0,0 +1,22 @@
+--  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.
+
+CREATE TABLE TRUSTED_OIDC_ISSUERS (
+    issuer_url    VARCHAR(2048) PRIMARY KEY NOT NULL,
+    dynamic_jwks  BOOLEAN       DEFAULT false NOT NULL,
+    registered_at TIMESTAMP     NOT NULL,
+    registered_by VARCHAR(2048),
+    cluster_name  VARCHAR(256)
+)
diff --git a/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableOracle.sql b/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableOracle.sql
new file mode 100644
index 000000000..2c0de4bd0
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableOracle.sql
@@ -0,0 +1,23 @@
+--  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.
+
+CREATE TABLE TRUSTED_OIDC_ISSUERS (
+    issuer_url    VARCHAR2(2048) NOT NULL,
+    dynamic_jwks  NUMBER(1)      DEFAULT 0 NOT NULL,
+    registered_at TIMESTAMP(6)   NOT NULL,
+    registered_by VARCHAR2(2048),
+    cluster_name  VARCHAR2(256),
+    PRIMARY KEY (issuer_url)
+)
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java
index 57271324c..904111888 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java
@@ -68,7 +68,8 @@ public class AbstractGatewayServicesTest {
         ServiceType.GATEWAY_STATUS_SERVICE,
         ServiceType.LDAP_SERVICE,
         ServiceType.LDAP_ROLES_LOOKUP_SERVICE,
-        ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE
+        ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE,
+        ServiceType.TRUSTED_OIDC_ISSUER_SERVICE
     };
 
     assertNotEquals(ServiceType.values(), orderedServiceTypes);
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerTest.java
new file mode 100644
index 000000000..a728e99ac
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.time.Instant;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class TrustedOidcIssuerTest {
+
+  @Test
+  public void testGetters() {
+    Instant now = Instant.now();
+    TrustedOidcIssuer issuer = new TrustedOidcIssuer(
+        "https://issuer.example.com", true, "cluster-a", now, "admin@example.com");
+
+    assertEquals("https://issuer.example.com", issuer.getIssuerUrl());
+    assertTrue(issuer.isDynamicJwks());
+    assertEquals("cluster-a", issuer.getClusterName());
+    assertEquals(now, issuer.getRegisteredAt());
+    assertEquals("admin@example.com", issuer.getRegisteredBy());
+  }
+
+  @Test
+  public void testNullableOptionalFields() {
+    TrustedOidcIssuer issuer = new TrustedOidcIssuer(
+        "https://issuer.example.com", false, null, Instant.now(), null);
+
+    assertNull("clusterName should be nullable", issuer.getClusterName());
+    assertNull("registeredBy should be nullable", issuer.getRegisteredBy());
+    assertFalse(issuer.isDynamicJwks());
+  }
+
+  @Test
+  public void testAllFieldsAreFinal() {
+    for (Field field : TrustedOidcIssuer.class.getDeclaredFields()) {
+      assertTrue("Field '" + field.getName() + "' must be final for immutability",
+          Modifier.isFinal(field.getModifiers()));
+    }
+  }
+
+  @Test
+  public void testNoSetterMethods() {
+    for (Method method : TrustedOidcIssuer.class.getDeclaredMethods()) {
+      assertFalse("Setter found in immutable POJO: " + method.getName(),
+          method.getName().startsWith("set"));
+    }
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
new file mode 100644
index 000000000..e8cb56015
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.knox.gateway.database.AbstractDataSourceFactory;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Validates that the TRUSTED_OIDC_ISSUERS DDL scripts parse and execute
+ * correctly against in-memory databases.
+ */
+public class TrustedOidcIssuersSchemaTest {
+
+  private static final String DERBY_DB = "trustedissuers";
+  private static final String DERBY_URL = "jdbc:derby:memory:" + DERBY_DB + ";create=true";
+  private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" + DERBY_DB + ";shutdown=true";
+  private static final String HSQL_URL = "jdbc:hsqldb:mem:trustedissuersschema;ifexists=false";
+  private static final String HSQL_USER = "SA";
+  private static final String HSQL_PASSWORD = "";
+
+  private static Connection derbyConn;
+  private static Connection hsqlConn;
+
+  @BeforeClass
+  public static void setUp() throws SQLException {
+    derbyConn = DriverManager.getConnection(DERBY_URL);
+    hsqlConn = DriverManager.getConnection(HSQL_URL, HSQL_USER, HSQL_PASSWORD);
+  }
+
+  @AfterClass
+  public static void tearDown() throws Exception {
+    // HSQLDB: follow JDBCTokenStateServiceTest pattern — new connection for SHUTDOWN
+    try (Connection conn = DriverManager.getConnection(HSQL_URL, HSQL_USER, HSQL_PASSWORD);
+         Statement stmt = conn.createStatement()) {
+      stmt.execute("SHUTDOWN");
+    }
+
+    // Derby: close the shared connection before issuing shutdown
+    if (derbyConn != null && !derbyConn.isClosed()) {
+      derbyConn.close();
+    }
+    try {
+      DriverManager.getConnection(DERBY_SHUTDOWN_URL);
+    } catch (SQLException e) {
+      // Derby signals a successful single-DB shutdown as error code 45000, state "08006"
+      if (!(e.getErrorCode() == 45000 && "08006".equals(e.getSQLState()))) {
+        throw e;
+      }
+    }
+  }
+
+  /**
+   * The Derby-dialect DDL must execute without error in a Derby in-memory
+   * database and leave the table queryable.
+   */
+  @Test
+  public void testDerbyDdlCreatesTable() throws Exception {
+    try (Statement stmt = derbyConn.createStatement()) {
+      stmt.execute(loadSql(AbstractDataSourceFactory.DERBY_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL));
+      try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM TRUSTED_OIDC_ISSUERS")) {
+        assertTrue(rs.next());
+        assertEquals(0, rs.getInt(1));
+      }
+    }
+  }
+
+  /**
+   * The standard SQL script uses IF NOT EXISTS. Running the script twice must
+   * not throw, confirming idempotency.
+   */
+  @Test
+  public void testStandardSqlIdempotent() throws Exception {
+    String sql = loadSql(AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL);
+    try (Statement stmt = hsqlConn.createStatement()) {
+      stmt.execute(sql);
+      // Second execution must succeed due to IF NOT EXISTS
+      stmt.execute(sql);
+      try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM TRUSTED_OIDC_ISSUERS")) {
+        assertTrue(rs.next());
+        assertEquals(0, rs.getInt(1));
+      }
+    }
+  }
+
+  private static String loadSql(String fileName) throws IOException {
+    try (InputStream is = TrustedOidcIssuersSchemaTest.class.getClassLoader().getResourceAsStream(fileName)) {
+      assertNotNull("SQL file not found on classpath: " + fileName, is);
+      return IOUtils.toString(is, StandardCharsets.UTF_8);
+    }
+  }
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java
index 21794bc21..85afaefae 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java
@@ -41,7 +41,8 @@ public enum ServiceType {
   GATEWAY_STATUS_SERVICE("GatewayStatusService"),
   LDAP_SERVICE("LDAPService"),
   LDAP_ROLES_LOOKUP_SERVICE("LDAPRoleLookupService"),
-  KNOXIDF_FEDERATED_IDENTITY_SERVICE("KnoxIDFFederatedIdentityService");
+  KNOXIDF_FEDERATED_IDENTITY_SERVICE("KnoxIDFFederatedIdentityService"),
+  TRUSTED_OIDC_ISSUER_SERVICE("TrustedOidcIssuerService");
 
   private final String serviceTypeName;
   private final String shortName;
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuer.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuer.java
new file mode 100644
index 000000000..fd4622209
--- /dev/null
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuer.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import java.time.Instant;
+
+public final class TrustedOidcIssuer {
+
+  private final String issuerUrl;
+  private final boolean dynamicJwks;
+  private final String clusterName;
+  private final Instant registeredAt;
+  private final String registeredBy;
+
+  public TrustedOidcIssuer(String issuerUrl, boolean dynamicJwks, String clusterName,
+      Instant registeredAt, String registeredBy) {
+    this.issuerUrl = issuerUrl;
+    this.dynamicJwks = dynamicJwks;
+    this.clusterName = clusterName;
+    this.registeredAt = registeredAt;
+    this.registeredBy = registeredBy;
+  }
+
+  public String getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  public boolean isDynamicJwks() {
+    return dynamicJwks;
+  }
+
+  /**
+   * @return the cluster name this issuer belongs to, or null if not cluster-scoped
+   */
+  public String getClusterName() {
+    return clusterName;
+  }
+
+  public Instant getRegisteredAt() {
+    return registeredAt;
+  }
+
+  /**
+   * @return the identity that registered this issuer, or null if not recorded
+   */
+  public String getRegisteredBy() {
+    return registeredBy;
+  }
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerService.java
new file mode 100644
index 000000000..273dbfa37
--- /dev/null
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerService.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import org.apache.knox.gateway.services.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Gateway service managing the registry of OIDC issuers trusted for JWT
+ * verification in Knox. For issuers registered for dynamic JWKS discovery,
+ * resolves JWKS URIs via OpenID Connect Discovery 1.0
+ * (https://openid.net/specs/openid-connect-discovery-1_0.html) rather than
+ * requiring statically configured JWKS endpoints.
+ */
+public interface TrustedOidcIssuerService extends Service {
+
+  /**
+   * Returns {@code true} if the given issuer URL is currently registered as
+   * trusted. This is the primary SSRF gate: callers must verify trust before
+   * requesting any external resource associated with an issuer.
+   */
+  boolean isTrusted(String issuerUrl);
+
+  /**
+   * Returns {@code true} if the given issuer URL is trusted and configured for
+   * OIDC discovery-based JWKS resolution. Returns {@code false} if the issuer
+   * is not trusted, or is trusted but configured for static JWKS only.
+   * <p>
+   * This method combines the trust check with the discovery-mode check.
+   * Callers may use it as a single guard without separately calling
+   * {@link #isTrusted(String)}.
+   */
+  boolean isDynamicJwks(String issuerUrl);
+
+  /**
+   * Resolves the JWKS URI for the given issuer URL using OIDC discovery.
+   * Callers should verify that the issuer is trusted and configured for OIDC
+   * discovery via {@link #isDynamicJwks(String)} before calling this method,
+   * as that check covers both conditions.
+   * <p>
+   * Returns {@link Optional#empty()} in all failure cases — including issuer not
+   * trusted, dynamic JWKS not configured, discovery document unreachable or
+   * malformed, or any internal error. Failure details are logged internally for
+   * troubleshooting. Callers should treat an empty result uniformly as
+   * "no trusted JWKS URI available" without branching on the failure cause.
+   */
+  Optional<String> resolveJwksUri(String issuerUrl);
+
+  /**
+   * Forces re-resolution of the JWKS URI for the given issuer URL, discarding
+   * any previously resolved value. Use this when a resolved JWKS URI is suspected
+   * to be stale (for example, if an issuer has changed its JWKS endpoint).
+   * Has no effect if the issuer is not registered or does not use OIDC discovery.
+   */
+  void refreshJwksUri(String issuerUrl);
+
+  /**
+   * Registers a new trusted OIDC issuer.
+   *
+   * @throws IllegalStateException if the maximum registered issuer limit is
+   *     reached
+   * @throws RuntimeException if registration fails due to a storage error such
+   *     as a duplicate issuer URL or a database failure
+   */
+  void register(TrustedOidcIssuer issuer);
+
+  /**
+   * Removes the given issuer URL from the trusted registry and invalidates any
+   * previously resolved JWKS URI for that issuer. Returns silently if the issuer
+   * is not currently registered.
+   *
+   * @throws RuntimeException if removal fails due to a storage error
+   */
+  void deregister(String issuerUrl);
+
+  /**
+   * Returns all currently registered trusted issuers.
+   */
+  List<TrustedOidcIssuer> list();
+}


(knox) 07/37: KNOX-3368 - Trusted OIDC Issuer admin API for Knox IDF (#1327)

Posted by sm...@apache.org.
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 740c6960c97d354f5e4ad7a327cdf539568cbc9f
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Fri Jul 24 01:43:07 2026 -0700

    KNOX-3368 - Trusted OIDC Issuer admin API for Knox IDF (#1327)
---
 .../knoxidf/TrustedOidcIssuersResource.java        | 227 +++++++++
 .../KnoxIDFAdminServiceDeploymentContributor.java  |  51 ++
 ...nox.gateway.deploy.ServiceDeploymentContributor |   1 +
 .../knoxidf/TrustedOidcIssuersResourceTest.java    | 551 +++++++++++++++++++++
 ...oxIDFAdminServiceDeploymentContributorTest.java | 145 ++++++
 .../org/apache/knox/gateway/audit/api/Action.java  |   1 +
 .../knox/gateway/audit/api/ResourceType.java       |   1 +
 7 files changed, 977 insertions(+)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
new file mode 100644
index 000000000..b8e8b393e
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.AuditServiceFactory;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.audit.api.ResourceType;
+import org.apache.knox.gateway.audit.log4j.audit.AuditConstants;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuer;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
+import org.apache.knox.gateway.util.JsonUtils;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.Principal;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Path(TrustedOidcIssuersResource.RESOURCE_PATH)
+@Produces(MediaType.APPLICATION_JSON)
+public class TrustedOidcIssuersResource {
+
+  static final String RESOURCE_PATH = "knoxidf/issuers-admin/v1/trusted-oidc-issuers";
+
+  private static final ObjectMapper MAPPER = new ObjectMapper();
+
+  // Non-final and package-private to allow test injection of a mock Auditor.
+  static Auditor auditor = AuditServiceFactory.getAuditService()
+      .getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME,
+          AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME);
+
+  @Context
+  private ServletContext servletContext;
+
+  @Context
+  private HttpServletRequest request;
+
+  private TrustedOidcIssuerService trustedIssuers;
+
+  @PostConstruct
+  public void init() {
+    final GatewayServices services = (GatewayServices)
+        servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+    trustedIssuers = services.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE);
+  }
+
+  @POST
+  @Consumes(MediaType.APPLICATION_JSON)
+  public Response registerIssuer(String body) {
+    String issuerUrl = "INVALID_REQUEST";
+    final String operatorId = getOperatorId();
+    String outcome = ActionOutcome.FAILURE;
+
+    try {
+      final Map<String, Object> parsed;
+      try {
+        parsed = MAPPER.readValue(body, new TypeReference<Map<String, Object>>() {});
+      } catch (IOException e) {
+        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", "Malformed JSON body");
+      }
+
+      final String rawUrl = (String) parsed.get("issuerUrl");
+      issuerUrl = (rawUrl != null && !rawUrl.isEmpty()) ? rawUrl : "UNKNOWN_ISSUER";
+
+      if (rawUrl == null || rawUrl.isEmpty()) {
+        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", "issuerUrl is required");
+      }
+      if (!isHttpsUrl(rawUrl)) {
+        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request",
+            "issuerUrl must use HTTPS scheme");
+      }
+      if (trustedIssuers.isTrusted(rawUrl)) {
+        return errorResponse(Response.Status.CONFLICT, "issuer_exists",
+            "Issuer already registered: " + rawUrl);
+      }
+
+      final boolean dynamicJwks = Boolean.TRUE.equals(parsed.get("dynamicJwks"));
+      final String clusterName = (String) parsed.get("clusterName");
+
+      trustedIssuers.register(new TrustedOidcIssuer(rawUrl, dynamicJwks, clusterName,
+          Instant.now(), operatorId));
+      outcome = ActionOutcome.SUCCESS;
+      return Response.status(Response.Status.CREATED).build();
+    } catch (RuntimeException e) {
+      return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error",
+          "Failed to register issuer");
+    } finally {
+      auditor.audit(Action.DELEGATION_LIFECYCLE, issuerUrl, ResourceType.TRUSTED_ISSUER,
+          outcome, "event_type=issuer_registered performed_by=" + auditLabel(operatorId));
+    }
+  }
+
+  @DELETE
+  public Response removeIssuer(@QueryParam("issuerUrl") String issuerUrl) {
+    final String operatorId = getOperatorId();
+    final String auditIssuerUrl = StringUtils.isBlank(issuerUrl) ? "UNKNOWN_ISSUER" : issuerUrl;
+    String outcome = ActionOutcome.FAILURE;
+
+    try {
+      if (StringUtils.isBlank(issuerUrl)) {
+        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request",
+            "issuerUrl query parameter is required");
+      }
+
+      // deregister is idempotent at the service layer: it returns silently if the issuer is
+      // not registered. Admins deleting a non-existent issuer receive the same 204 and audit
+      // event as a successful delete — there is no separate 404 path at this layer.
+      trustedIssuers.deregister(issuerUrl);
+      outcome = ActionOutcome.SUCCESS;
+      return Response.noContent().build();
+    } catch (RuntimeException e) {
+      return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error",
+          "Failed to remove issuer");
+    } finally {
+      auditor.audit(Action.DELEGATION_LIFECYCLE, auditIssuerUrl, ResourceType.TRUSTED_ISSUER,
+          outcome, "event_type=issuer_removed performed_by=" + auditLabel(operatorId));
+    }
+  }
+
+  @GET
+  public Response listIssuers() {
+    final List<Map<String, Object>> result = trustedIssuers.list().stream()
+        .map(this::issuerToMap)
+        .collect(Collectors.toList());
+    return Response.ok(JsonUtils.renderAsJsonString(result)).build();
+  }
+
+  @POST
+  @Path("/refresh-jwks")
+  public Response refreshJwksUri(@QueryParam("issuerUrl") String issuerUrl) {
+    final String operatorId = getOperatorId();
+    final String auditIssuerUrl = StringUtils.isBlank(issuerUrl) ? "UNKNOWN_ISSUER" : issuerUrl;
+    String outcome = ActionOutcome.FAILURE;
+
+    try {
+      if (StringUtils.isBlank(issuerUrl)) {
+        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request",
+            "issuerUrl query parameter is required");
+      }
+
+      // No-op at the service layer if the issuer is not registered or not configured for
+      // dynamic JWKS; still returns 204 so the caller does not need to check existence first.
+      trustedIssuers.refreshJwksUri(issuerUrl);
+      outcome = ActionOutcome.SUCCESS;
+      return Response.noContent().build();
+    } catch (RuntimeException e) {
+      return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error",
+          "Failed to refresh JWKS URI");
+    } finally {
+      auditor.audit(Action.DELEGATION_LIFECYCLE, auditIssuerUrl, ResourceType.TRUSTED_ISSUER,
+          outcome, "event_type=issuer_jwks_refreshed performed_by=" + auditLabel(operatorId));
+    }
+  }
+
+  private String getOperatorId() {
+    final Principal principal = request.getUserPrincipal();
+    return principal != null ? principal.getName() : null;
+  }
+
+  private static String auditLabel(String operatorId) {
+    return operatorId != null ? operatorId : "ANONYMOUS";
+  }
+
+  private Map<String, Object> issuerToMap(TrustedOidcIssuer issuer) {
+    final Map<String, Object> map = new LinkedHashMap<>();
+    map.put("issuerUrl", issuer.getIssuerUrl());
+    map.put("dynamicJwks", issuer.isDynamicJwks());
+    map.put("clusterName", issuer.getClusterName());
+    map.put("registeredAt",
+        issuer.getRegisteredAt() != null ? issuer.getRegisteredAt().toString() : null);
+    map.put("registeredBy", issuer.getRegisteredBy());
+    return map;
+  }
+
+  private static boolean isHttpsUrl(String url) {
+    try {
+      return "https".equalsIgnoreCase(new URI(url).getScheme());
+    } catch (URISyntaxException e) {
+      return false;
+    }
+  }
+
+  private static Response errorResponse(Response.Status status, String error, String description) {
+    final Map<String, String> body = new LinkedHashMap<>();
+    body.put("error", error);
+    body.put("error_description", description);
+    return Response.status(status).entity(JsonUtils.renderAsJsonString(body)).build();
+  }
+}
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java
new file mode 100644
index 000000000..060774a93
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf.deploy;
+
+import org.apache.knox.gateway.jersey.JerseyServiceDeploymentContributorBase;
+
+/**
+ * Deployment contributor for the KNOXIDF_ADMIN service role, which hosts the
+ * trusted OIDC issuer admin REST API. This contributor registers
+ * {@link org.apache.knox.gateway.service.knoxidf.TrustedOidcIssuersResource}
+ * under the {@code knoxidf/issuers-admin/**?**} pattern, which is disjoint from
+ * the KNOXIDF role's {@code knoxidf/api/**?**} pattern. This ensures the KNOXIDF
+ * role cannot serve admin endpoints, and that per-role AclsAuthz authorization
+ * ({@code KNOXIDF_ADMIN.acl}) applies only to trusted-issuer admin requests.
+ */
+public class KnoxIDFAdminServiceDeploymentContributor extends JerseyServiceDeploymentContributorBase {
+
+  @Override
+  public String getRole() {
+    return "KNOXIDF_ADMIN";
+  }
+
+  @Override
+  public String getName() {
+    return "KNOXIDF_ADMIN";
+  }
+
+  @Override
+  protected String[] getPackages() {
+    return new String[] { "org.apache.knox.gateway.service.knoxidf" };
+  }
+
+  @Override
+  protected String[] getPatterns() {
+    return new String[] { "knoxidf/issuers-admin/**?**" };
+  }
+}
diff --git a/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor
index 49fc687f1..1fca75abb 100644
--- a/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor
+++ b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor
@@ -16,3 +16,4 @@
 # limitations under the License.
 ##########################################################################
 org.apache.knox.gateway.service.knoxidf.deploy.KnoxIDFServiceDeploymentContributor
+org.apache.knox.gateway.service.knoxidf.deploy.KnoxIDFAdminServiceDeploymentContributor
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java
new file mode 100644
index 000000000..e082d3255
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java
@@ -0,0 +1,551 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.audit.api.ResourceType;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuer;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+import java.lang.reflect.Field;
+import java.security.Principal;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class TrustedOidcIssuersResourceTest {
+
+  private static final String ISSUER_A = "https://issuer-a.example.com";
+  private static final String ISSUER_B = "https://issuer-b.example.com";
+  private static final String OPERATOR = "admin";
+
+  // Capture the real static Auditor so @After can restore it.
+  private static final Auditor ORIGINAL_AUDITOR = TrustedOidcIssuersResource.auditor;
+
+  private TrustedOidcIssuersResource resource;
+  private TrustedOidcIssuerService mockService;
+  private Auditor mockAuditor;
+
+  @Before
+  public void setUp() throws Exception {
+    mockService = EasyMock.createMock(TrustedOidcIssuerService.class);
+    mockAuditor = EasyMock.createMock(Auditor.class);
+    TrustedOidcIssuersResource.auditor = mockAuditor;
+    resource = buildResource(buildPrincipal(OPERATOR));
+  }
+
+  @After
+  public void tearDown() {
+    TrustedOidcIssuersResource.auditor = ORIGINAL_AUDITOR;
+  }
+
+  // ---------------------------------------------------------------------------
+  // POST /register
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testRegisterIssuer() {
+    EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once();
+    final Capture<TrustedOidcIssuer> capturedIssuer = EasyMock.newCapture();
+    mockService.register(EasyMock.capture(capturedIssuer));
+    EasyMock.expectLastCall().once();
+    final Capture<String> auditMsg = EasyMock.newCapture();
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS),
+        EasyMock.capture(auditMsg));
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null));
+
+    assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
+    assertEquals(ISSUER_A, capturedIssuer.getValue().getIssuerUrl());
+    assertFalse(capturedIssuer.getValue().isDynamicJwks());
+    assertNull(capturedIssuer.getValue().getClusterName());
+    assertEquals(OPERATOR, capturedIssuer.getValue().getRegisteredBy());
+    assertNotNull(capturedIssuer.getValue().getRegisteredAt());
+    assertTrue(auditMsg.getValue().contains("event_type=issuer_registered"));
+    assertTrue(auditMsg.getValue().contains("performed_by=" + OPERATOR));
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterWithClusterNameAndDynamicJwks() {
+    EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once();
+    final Capture<TrustedOidcIssuer> capturedIssuer = EasyMock.newCapture();
+    mockService.register(EasyMock.capture(capturedIssuer));
+    EasyMock.expectLastCall().once();
+    expectAudit(ISSUER_A, ActionOutcome.SUCCESS, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.registerIssuer(
+        buildRegisterBody(ISSUER_A, true, "production-cluster"));
+
+    assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
+    assertTrue(capturedIssuer.getValue().isDynamicJwks());
+    assertEquals("production-cluster", capturedIssuer.getValue().getClusterName());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterNonHttpsUrl() {
+    final String httpUrl = "http://insecure.example.com";
+    // No service calls expected; audit fires with the non-HTTPS URL as resource name.
+    expectAudit(httpUrl, ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.registerIssuer(buildRegisterBody(httpUrl, false, null));
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterMissingIssuerUrl() {
+    // issuerUrl field absent from JSON → sentinel UNKNOWN_ISSUER used as audit resource name.
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
+        resource.registerIssuer("{\"dynamicJwks\":false}").getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterEmptyIssuerUrl() {
+    // Empty string issuerUrl → same sentinel UNKNOWN_ISSUER as null case.
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
+        resource.registerIssuer("{\"issuerUrl\":\"\",\"dynamicJwks\":false}").getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterInvalidJson() {
+    // JSON parse failure before URL is known → sentinel INVALID_REQUEST as audit resource name.
+    expectAudit("INVALID_REQUEST", ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
+        resource.registerIssuer("{ not valid json }").getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testDuplicateIssuer() {
+    EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(true).once();
+    expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null));
+
+    assertEquals(Response.Status.CONFLICT.getStatusCode(), response.getStatus());
+    assertErrorField(response, "issuer_exists");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterNullPrincipal() throws Exception {
+    final TrustedOidcIssuersResource res = buildResource(null);
+    EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once();
+    final Capture<TrustedOidcIssuer> capturedIssuer = EasyMock.newCapture();
+    mockService.register(EasyMock.capture(capturedIssuer));
+    EasyMock.expectLastCall().once();
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS),
+        EasyMock.contains("performed_by=ANONYMOUS"));
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.CREATED.getStatusCode(),
+        res.registerIssuer(buildRegisterBody(ISSUER_A, false, null)).getStatus());
+    assertNull(capturedIssuer.getValue().getRegisteredBy());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRemoveNullPrincipalAuditsAnonymous() throws Exception {
+    final TrustedOidcIssuersResource res = buildResource(null);
+    mockService.deregister(ISSUER_A);
+    EasyMock.expectLastCall().once();
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS),
+        EasyMock.contains("performed_by=ANONYMOUS"));
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.NO_CONTENT.getStatusCode(),
+        res.removeIssuer(ISSUER_A).getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRefreshJwksNullPrincipalAuditsAnonymous() throws Exception {
+    final TrustedOidcIssuersResource res = buildResource(null);
+    mockService.refreshJwksUri(ISSUER_A);
+    EasyMock.expectLastCall().once();
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS),
+        EasyMock.contains("performed_by=ANONYMOUS"));
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.NO_CONTENT.getStatusCode(),
+        res.refreshJwksUri(ISSUER_A).getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testAuditRegisterStorageFailure() {
+    EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once();
+    mockService.register(EasyMock.anyObject(TrustedOidcIssuer.class));
+    EasyMock.expectLastCall().andThrow(new RuntimeException("DB error")).once();
+    expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
+        resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null)).getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  // ---------------------------------------------------------------------------
+  // DELETE /
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testRemoveRegisteredIssuer() {
+    mockService.deregister(ISSUER_A);
+    EasyMock.expectLastCall().once();
+    final Capture<String> auditMsg = EasyMock.newCapture();
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS),
+        EasyMock.capture(auditMsg));
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.NO_CONTENT.getStatusCode(),
+        resource.removeIssuer(ISSUER_A).getStatus());
+    assertTrue(auditMsg.getValue().contains("event_type=issuer_removed"));
+    assertTrue(auditMsg.getValue().contains("performed_by=" + OPERATOR));
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRemoveMissingIssuerUrl() {
+    // Null models a request where ?issuerUrl= was omitted entirely (JAX-RS injects null).
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_removed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.removeIssuer(null);
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRemoveEmptyIssuerUrl() {
+    // Empty string models ?issuerUrl= with no value.
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_removed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.removeIssuer("");
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRemoveWhitespaceIssuerUrl() {
+    // Whitespace-only is not a valid HTTPS URL; fail fast rather than propagating to the service.
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_removed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.removeIssuer("   ");
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testAuditRemoveStorageFailure() {
+    mockService.deregister(ISSUER_A);
+    EasyMock.expectLastCall().andThrow(new RuntimeException("DB error")).once();
+    expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_removed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
+        resource.removeIssuer(ISSUER_A).getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // POST /refresh-jwks
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testRefreshJwksUri() {
+    mockService.refreshJwksUri(ISSUER_A);
+    EasyMock.expectLastCall().once();
+    final Capture<String> auditMsg = EasyMock.newCapture();
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS),
+        EasyMock.capture(auditMsg));
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.NO_CONTENT.getStatusCode(),
+        resource.refreshJwksUri(ISSUER_A).getStatus());
+    assertTrue(auditMsg.getValue().contains("event_type=issuer_jwks_refreshed"));
+    assertTrue(auditMsg.getValue().contains("performed_by=" + OPERATOR));
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRefreshMissingIssuerUrl() {
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_jwks_refreshed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.refreshJwksUri(null);
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRefreshEmptyIssuerUrl() {
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_jwks_refreshed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.refreshJwksUri("");
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRefreshWhitespaceIssuerUrl() {
+    expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_jwks_refreshed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.refreshJwksUri("   ");
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRefreshJwksUriAuditsStorageFailure() {
+    mockService.refreshJwksUri(ISSUER_A);
+    EasyMock.expectLastCall().andThrow(new RuntimeException("Cache error")).once();
+    expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_jwks_refreshed");
+    EasyMock.replay(mockService, mockAuditor);
+
+    assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
+        resource.refreshJwksUri(ISSUER_A).getStatus());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // GET /
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testListIssuers() throws Exception {
+    final Instant now = Instant.now();
+    final TrustedOidcIssuer issuerA = new TrustedOidcIssuer(ISSUER_A, true, "cluster-a",
+        now, OPERATOR);
+    final TrustedOidcIssuer issuerB = new TrustedOidcIssuer(ISSUER_B, false, null,
+        now, null);
+    EasyMock.expect(mockService.list()).andReturn(Arrays.asList(issuerA, issuerB)).once();
+    // listIssuers does not audit; no expectations set on mockAuditor.
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.listIssuers();
+
+    assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+    final List<Map<String, Object>> body = parseJsonList(response.getEntity().toString());
+    assertEquals(2, body.size());
+
+    final Map<String, Object> a = findByIssuerUrl(body, ISSUER_A);
+    assertEquals(true, a.get("dynamicJwks"));
+    assertEquals("cluster-a", a.get("clusterName"));
+    assertNotNull(a.get("registeredAt"));
+    assertEquals(OPERATOR, a.get("registeredBy"));
+
+    final Map<String, Object> b = findByIssuerUrl(body, ISSUER_B);
+    assertEquals(false, b.get("dynamicJwks"));
+    assertNull(b.get("clusterName"));
+    assertNull(b.get("registeredBy"));
+    assertNotNull(b.get("registeredAt"));
+
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testListReturnsEmptyArray() throws Exception {
+    EasyMock.expect(mockService.list()).andReturn(Collections.emptyList()).once();
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.listIssuers();
+
+    assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+    assertTrue(parseJsonList(response.getEntity().toString()).isEmpty());
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  // ---------------------------------------------------------------------------
+  // @PostConstruct wiring
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testInitWiresServiceFromGatewayServices() throws Exception {
+    final TrustedOidcIssuerService svc = EasyMock.createNiceMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(svc);
+
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(svc).once();
+    EasyMock.replay(gws);
+
+    final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+    EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(gws).once();
+    EasyMock.replay(ctx);
+
+    final TrustedOidcIssuersResource res = new TrustedOidcIssuersResource();
+    injectField(res, "servletContext", ctx);
+    injectField(res, "request", buildRequest(buildPrincipal(OPERATOR)));
+    res.init();
+
+    EasyMock.verify(gws, ctx);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------------
+
+  private TrustedOidcIssuersResource buildResource(Principal principal) throws Exception {
+    final TrustedOidcIssuersResource res = new TrustedOidcIssuersResource();
+    injectField(res, "request", buildRequest(principal));
+    injectField(res, "trustedIssuers", mockService);
+    return res;
+  }
+
+  private HttpServletRequest buildRequest(Principal principal) {
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getUserPrincipal()).andReturn(principal).anyTimes();
+    EasyMock.replay(req);
+    return req;
+  }
+
+  private Principal buildPrincipal(String name) {
+    if (name == null) {
+      return null;
+    }
+    final Principal p = EasyMock.createNiceMock(Principal.class);
+    EasyMock.expect(p.getName()).andReturn(name).anyTimes();
+    EasyMock.replay(p);
+    return p;
+  }
+
+  private void expectAudit(String issuerUrl, String outcome, String eventType) {
+    mockAuditor.audit(
+        EasyMock.eq(Action.DELEGATION_LIFECYCLE),
+        EasyMock.eq(issuerUrl),
+        EasyMock.eq(ResourceType.TRUSTED_ISSUER),
+        EasyMock.eq(outcome),
+        EasyMock.contains(eventType));
+    EasyMock.expectLastCall().once();
+  }
+
+  private static String buildRegisterBody(String issuerUrl, boolean dynamicJwks,
+      String clusterName) {
+    final StringBuilder sb = new StringBuilder("{");
+    if (issuerUrl != null) {
+      sb.append("\"issuerUrl\":\"").append(issuerUrl).append("\",");
+    }
+    sb.append("\"dynamicJwks\":").append(dynamicJwks);
+    if (clusterName != null) {
+      sb.append(",\"clusterName\":\"").append(clusterName).append("\"");
+    }
+    sb.append("}");
+    return sb.toString();
+  }
+
+  private static void assertErrorField(Response response, String expectedError) {
+    assertNotNull(response.getEntity());
+    final String body = response.getEntity().toString();
+    assertFalse("Error body must not be empty", body.isEmpty());
+    assertTrue("Expected error field '" + expectedError + "' in: " + body,
+        body.contains(expectedError));
+  }
+
+  private static List<Map<String, Object>> parseJsonList(String json) throws Exception {
+    return new ObjectMapper().readValue(json, new TypeReference<List<Map<String, Object>>>() {});
+  }
+
+  private static Map<String, Object> findByIssuerUrl(List<Map<String, Object>> list,
+      String issuerUrl) {
+    return list.stream()
+        .filter(m -> issuerUrl.equals(m.get("issuerUrl")))
+        .findFirst()
+        .orElseThrow(() -> new AssertionError("Issuer not found: " + issuerUrl));
+  }
+
+  private static void injectField(Object target, String fieldName, Object value) throws Exception {
+    final Field field = target.getClass().getDeclaredField(fieldName);
+    field.setAccessible(true);
+    field.set(target, value);
+  }
+}
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java
new file mode 100644
index 000000000..cd3494b4c
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf.deploy;
+
+import org.apache.knox.gateway.deploy.DeploymentContext;
+import org.apache.knox.gateway.deploy.ServiceDeploymentContributor;
+import org.apache.knox.gateway.descriptor.FilterParamDescriptor;
+import org.apache.knox.gateway.descriptor.GatewayDescriptor;
+import org.apache.knox.gateway.descriptor.ResourceDescriptor;
+import org.apache.knox.gateway.topology.Service;
+import org.apache.knox.gateway.topology.Topology;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.ServiceLoader;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Unit tests for {@link KnoxIDFAdminServiceDeploymentContributor}.
+ *
+ * Tests verify: (1) the Service SPI registration (ServiceLoader discovery);
+ * (2) the values returned by the contributor's property methods (role, name,
+ * packages, patterns); and (3) that {@code contributeService()} correctly wires
+ * the gateway descriptor resource with the right role and pattern — verifies
+ * that a deployed KNOXIDF_ADMIN service role produces a resource descriptor
+ * with the correct role and URL pattern, as required for Knox gateway routing.
+ */
+public class KnoxIDFAdminServiceDeploymentContributorTest {
+
+  @Test
+  public void testRoleAndName() {
+    final KnoxIDFAdminServiceDeploymentContributor c =
+        new KnoxIDFAdminServiceDeploymentContributor();
+    assertEquals("KNOXIDF_ADMIN", c.getRole());
+    assertEquals("KNOXIDF_ADMIN", c.getName());
+  }
+
+  @Test
+  public void testPackages() {
+    final KnoxIDFAdminServiceDeploymentContributor c =
+        new KnoxIDFAdminServiceDeploymentContributor();
+    final String[] packages = c.getPackages();
+    assertNotNull(packages);
+    assertTrue("Expected org.apache.knox.gateway.service.knoxidf in packages",
+        Arrays.asList(packages).contains("org.apache.knox.gateway.service.knoxidf"));
+  }
+
+  @Test
+  public void testPatterns() {
+    final KnoxIDFAdminServiceDeploymentContributor c =
+        new KnoxIDFAdminServiceDeploymentContributor();
+    final String[] patterns = c.getPatterns();
+    assertNotNull(patterns);
+    // Distinct from KnoxIDFServiceDeploymentContributor's "knoxidf/api/**?**" so that the
+    // KNOXIDF role cannot accidentally serve admin endpoints, and so that per-role AclsAuthz
+    // params (KNOXIDF_ADMIN.acl) apply only to trusted-issuer admin requests.
+    assertTrue("Expected knoxidf/issuers-admin/**?** in patterns",
+        Arrays.asList(patterns).contains("knoxidf/issuers-admin/**?**"));
+  }
+
+  @Test
+  public void testServiceLoaderDiscovery() {
+    for (ServiceDeploymentContributor c :
+        ServiceLoader.load(ServiceDeploymentContributor.class)) {
+      if (c instanceof KnoxIDFAdminServiceDeploymentContributor) {
+        assertEquals("KNOXIDF_ADMIN", c.getRole());
+        assertEquals("KNOXIDF_ADMIN", c.getName());
+        return;
+      }
+    }
+    fail("KnoxIDFAdminServiceDeploymentContributor not discoverable via ServiceLoader");
+  }
+
+  /**
+   * Verifies that {@code contributeService()} sets the correct service role and URL pattern
+   * on the gateway resource descriptor. This exercises the inherited
+   * {@code JerseyServiceDeploymentContributorBase.contributeService()} with the concrete
+   * values from {@code getPackages()} and {@code getPatterns()}.
+   */
+  @Test
+  public void testContributeService() throws Exception {
+    final KnoxIDFAdminServiceDeploymentContributor contributor =
+        new KnoxIDFAdminServiceDeploymentContributor();
+
+    // Mock FilterParamDescriptor for the jersey.config.server.provider.packages param.
+    final FilterParamDescriptor param = EasyMock.createNiceMock(FilterParamDescriptor.class);
+    EasyMock.expect(param.name(EasyMock.anyString())).andReturn(param).anyTimes();
+    EasyMock.expect(param.value(EasyMock.anyString())).andReturn(param).anyTimes();
+    EasyMock.replay(param);
+
+    // Capture role and pattern set on the resource descriptor.
+    final Capture<String> capturedRole = EasyMock.newCapture();
+    final Capture<String> capturedPattern = EasyMock.newCapture();
+    final ResourceDescriptor resource = EasyMock.createNiceMock(ResourceDescriptor.class);
+    EasyMock.expect(resource.role(EasyMock.capture(capturedRole))).andReturn(resource).anyTimes();
+    EasyMock.expect(resource.pattern(EasyMock.capture(capturedPattern)))
+        .andReturn(resource).anyTimes();
+    EasyMock.expect(resource.createFilterParam()).andReturn(param).anyTimes();
+    EasyMock.expect(resource.filters()).andReturn(Collections.emptyList()).anyTimes();
+    EasyMock.replay(resource);
+
+    final GatewayDescriptor descriptor = EasyMock.createNiceMock(GatewayDescriptor.class);
+    EasyMock.expect(descriptor.addResource()).andReturn(resource).anyTimes();
+    EasyMock.replay(descriptor);
+
+    // Use an empty topology so the base-class addXxxFilter calls are no-ops, isolating
+    // the test to this contributor's specific contributions (role and pattern)"
+    final Topology topology = new Topology();
+
+    final DeploymentContext context = EasyMock.createNiceMock(DeploymentContext.class);
+    EasyMock.expect(context.getGatewayDescriptor()).andReturn(descriptor).anyTimes();
+    EasyMock.expect(context.getTopology()).andReturn(topology).anyTimes();
+    EasyMock.replay(context);
+
+    final Service service = new Service();
+    service.setRole("KNOXIDF_ADMIN");
+    service.setName("KNOXIDF_ADMIN");
+
+    contributor.contributeService(context, service);
+
+    assertEquals("KNOXIDF_ADMIN", capturedRole.getValue());
+    assertEquals("knoxidf/issuers-admin/**?**", capturedPattern.getValue());
+  }
+}
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java
index 7057d56b0..3a77f60bb 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java
@@ -31,5 +31,6 @@ public abstract class Action {
   public static final String DISPATCH = "dispatch";
   public static final String ACCESS = "access";
   public static final String WEBSHELL = "webshell";
+  public static final String DELEGATION_LIFECYCLE = "delegation-lifecycle";
 
 }
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java
index a9eb21186..7c0624056 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java
@@ -25,5 +25,6 @@ public abstract class ResourceType {
   public static final String TOPOLOGY = "topology";
   public static final String PRINCIPAL = "principal";
   public static final String PROCESS = "process";
+  public static final String TRUSTED_ISSUER = "trusted-issuer";
 
 }


(knox) 28/37: KNOX-3414: build a well-formed success redirect when redirect_uri has a query (review finding M7)

Posted by sm...@apache.org.
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 5827bb196b3e4d082dd6d1dd6fdb545b51985497
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:36:59 2026 +0200

    KNOX-3414: build a well-formed success redirect when redirect_uri has a query (review finding M7)
    
    redirectToAuthSuccess appended "?code=...&state=..." unconditionally. When the
    registered redirect_uri already carried a query string (e.g.
    https://app.example/cb?ui=dark), the result had two "?" separators, so the
    client parsed neither code nor state and the authorization-code flow silently
    failed for those clients.
    
    Extract buildSuccessRedirect, which uses "&" when the redirect_uri already
    contains a query string and "?" otherwise, preserving any pre-existing params.
    
    Covered by AuthorizeResourceSuccessRedirectTest.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/AuthorizeResource.java | 23 ++++++--
 .../AuthorizeResourceSuccessRedirectTest.java      | 67 ++++++++++++++++++++++
 2 files changed, 85 insertions(+), 5 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index 4b6d7ec7a..84c213c95 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -263,15 +263,28 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
     }
 
     private Response redirectToAuthSuccess(final AuthorizeRequestMetadata authorizeRequestMetadata, final String code) {
-        final String redirectLocation;
         try {
-            redirectLocation = authorizeRequestMetadata.getRedirectUri()
-                    + "?code=" + URLEncoder.encode(code, UTF_8)
-                    + "&state=" + URLEncoder.encode(authorizeRequestMetadata.getState(), UTF_8);
+            final String redirectLocation = buildSuccessRedirect(
+                    authorizeRequestMetadata.getRedirectUri(), code, authorizeRequestMetadata.getState());
+            return Response.seeOther(URI.create(redirectLocation)).build();
         } catch (UnsupportedEncodingException e) {
             throw new RuntimeException(e); //This should never happen with UTF-8
         }
-        return Response.seeOther(URI.create(redirectLocation)).build();
+    }
+
+    /**
+     * Appends the {@code code} and {@code state} authorization-response params to the client's
+     * registered redirect_uri. Uses {@code &} as the separator when the redirect_uri already carries
+     * a query string and {@code ?} otherwise, so a registered URI such as
+     * {@code https://app.example/cb?ui=1} produces {@code ...?ui=1&code=...&state=...} rather than a
+     * malformed second {@code ?} that the client would fail to parse. Package-private for testing.
+     */
+    static String buildSuccessRedirect(final String redirectUri, final String code, final String state)
+            throws UnsupportedEncodingException {
+        final String separator = redirectUri.contains("?") ? "&" : "?";
+        return redirectUri
+                + separator + "code=" + URLEncoder.encode(code, UTF_8)
+                + "&state=" + URLEncoder.encode(state, UTF_8);
     }
 
     @GET
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java
new file mode 100644
index 000000000..30da0eb25
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.net.URI;
+import java.util.List;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.utils.URLEncodedUtils;
+import org.junit.Test;
+
+/**
+ * Verifies the authorization-code success redirect stays well-formed when the registered
+ * redirect_uri already carries a query string (review finding M7). Appending {@code ?code=...}
+ * unconditionally produced a second {@code ?}, so the client parsed neither code nor state.
+ */
+public class AuthorizeResourceSuccessRedirectTest {
+
+  private static List<NameValuePair> queryParams(final String location) {
+    return URLEncodedUtils.parse(URI.create(location), java.nio.charset.StandardCharsets.UTF_8);
+  }
+
+  private static String param(final List<NameValuePair> params, final String name) {
+    return params.stream().filter(p -> p.getName().equals(name)).map(NameValuePair::getValue)
+        .findFirst().orElse(null);
+  }
+
+  @Test
+  public void testRedirectUriWithoutQueryUsesQuestionMark() throws Exception {
+    final String location = AuthorizeResource.buildSuccessRedirect(
+        "https://app.example/cb", "the code", "the state");
+    assertTrue("A redirect_uri without a query must start its params with '?'.",
+        location.startsWith("https://app.example/cb?"));
+    final List<NameValuePair> params = queryParams(location);
+    assertEquals("the code", param(params, "code"));
+    assertEquals("the state", param(params, "state"));
+  }
+
+  @Test
+  public void testRedirectUriWithExistingQueryUsesAmpersand() throws Exception {
+    final String location = AuthorizeResource.buildSuccessRedirect(
+        "https://app.example/cb?ui=dark", "the code", "the state");
+    // Exactly one '?' -- the code/state must be appended with '&', not a second '?'.
+    assertEquals("There must be exactly one query separator.", 1, location.chars().filter(c -> c == '?').count());
+    final List<NameValuePair> params = queryParams(location);
+    assertEquals("The pre-existing query param must survive.", "dark", param(params, "ui"));
+    assertEquals("the code", param(params, "code"));
+    assertEquals("the state", param(params, "state"));
+  }
+}


(knox) 31/37: KNOX-3414: publish multiple JWKs and select verification key by kid

Posted by sm...@apache.org.
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 a2c1470c86bdd0eb45338447c0fc6ed2b5c498a0
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 00:35:01 2026 +0200

    KNOX-3414: publish multiple JWKs and select verification key by kid
    
    Support a manual signing-key rotation for gateway-signed JWTs (KnoxIDF
    OAuth2/OIDC tokens and KnoxSSO/knoxtoken tokens) without invalidating
    already-issued tokens.
    
    - GatewayConfig: new optional param gateway.signing.key.aliases.additional,
      a comma-separated list of signing-keystore aliases retained alongside the
      current gateway.signing.key.alias. New getSigningKeyAliases() returns the
      current key first, then the additional keys (de-duplicated). Defaults to
      just the current key, so single-key deployments are unchanged.
    - JWKSResource: publish one JWK per configured alias on /jwks, each with its
      own kid (SHA-256 thumbprint), instead of only the current key. A single-key
      deployment still yields exactly one JWK.
    - DefaultTokenAuthorityService: the single-key RSA verification fallback is
      now kid-aware. When more than one alias is configured it matches the token's
      kid header against each configured key's thumbprint and verifies with the
      matching (possibly rotated-out) key; otherwise it falls back to the current
      key, preserving the historical behavior. kid stamping on issuance was
      already in place.
    
    Tests: JWKSResource publishes one JWK per alias with distinct kids;
    DefaultTokenAuthorityService selects a rotated-out key by kid and rejects it
    once that alias is removed; GatewayConfigImpl alias parsing/dedup/none.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/config/impl/GatewayConfigImpl.java     | 19 +++++++
 .../token/impl/DefaultTokenAuthorityService.java   | 54 +++++++++++++++++-
 .../gateway/config/impl/GatewayConfigImplTest.java | 28 ++++++++++
 .../impl/DefaultTokenAuthorityServiceTest.java     | 64 ++++++++++++++++++++++
 .../gateway/service/knoxtoken/JWKSResource.java    | 59 +++++++++++++-------
 .../service/knoxtoken/JWKSResourceTest.java        | 57 +++++++++++++++++++
 .../apache/knox/gateway/config/GatewayConfig.java  | 20 +++++++
 7 files changed, 278 insertions(+), 23 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
index f14bc0fe9..6ad4fdf1d 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
@@ -1017,6 +1017,25 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig {
     }
   }
 
+  @Override
+  public List<String> getSigningKeyAliases() {
+    final List<String> aliases = new ArrayList<>();
+    final String current = getSigningKeyAlias();
+    if (current != null) {
+      aliases.add(current);
+    }
+    final String additional = get(SIGNING_KEY_ALIASES_ADDITIONAL);
+    if (additional != null && !additional.trim().isEmpty() && !"none".equalsIgnoreCase(additional.trim())) {
+      for (String alias : additional.trim().split("\\s*,\\s*")) {
+        // Skip blanks and de-duplicate so the current key is never published/checked twice.
+        if (!alias.isEmpty() && !aliases.contains(alias)) {
+          aliases.add(alias);
+        }
+      }
+    }
+    return aliases;
+  }
+
   @Override
   public List<String> getGlobalRulesServices() {
     String value = get( GLOBAL_RULES_SERVICES );
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
index 034b27ce5..98b720a3d 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
@@ -32,14 +32,17 @@ import java.security.interfaces.RSAPrivateKey;
 import java.security.interfaces.RSAPublicKey;
 import java.text.ParseException;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
 import com.nimbusds.jose.JWSSigner;
 import com.nimbusds.jose.JWSVerifier;
 import com.nimbusds.jose.KeyLengthException;
@@ -202,17 +205,62 @@ public class DefaultTokenAuthorityService implements JWTokenAuthority, Service {
     try {
       PublicKey key = publicKey;
       if (key == null) {
-        key = keystoreService.getSigningKeystore().getCertificate(getSigningKeyAlias()).getPublicKey();
+        key = selectVerificationKey(token);
       }
       final JWSVerifier verifier = new RSASSAVerifier((RSAPublicKey) key);
-      // TODO: interrogate the token for issuer claim in order to determine the public key to use for verification
-      // consider jwk for specifying the key too
       return token.verify(verifier);
     } catch (KeyStoreException | KeystoreServiceException e) {
       throw new TokenServiceException("Cannot verify token.", e);
     }
   }
 
+  /**
+   * Selects the public key to verify a gateway-signed RSA token with. When more than one signing
+   * key alias is configured, the token's {@code kid} header is matched against each configured
+   * key's SHA-256 thumbprint so a token signed by a rotated-out key still verifies. When there is a
+   * single configured key, or the token carries no matching {@code kid}, this falls back to the
+   * current signing key — the historical single-key behavior.
+   */
+  private PublicKey selectVerificationKey(JWT token) throws KeyStoreException, KeystoreServiceException {
+    final KeyStore keystore = keystoreService.getSigningKeystore();
+    final List<String> aliases = getVerificationKeyAliases();
+    // Fast path / backward compatibility: a single configured key means no kid selection is needed.
+    if (aliases.size() > 1) {
+      final String kid = extractKid(token);
+      if (kid != null) {
+        for (final String alias : aliases) {
+          final Certificate cert = keystore.getCertificate(alias);
+          if (cert == null || !(cert.getPublicKey() instanceof RSAPublicKey)) {
+            continue;
+          }
+          try {
+            if (kid.equals(TokenUtils.getThumbprint((RSAPublicKey) cert.getPublicKey(), "SHA-256"))) {
+              return cert.getPublicKey();
+            }
+          } catch (JOSEException e) {
+            // Cannot compute this key's thumbprint; skip it and try the next alias.
+            LOG.errorGettingKid(e.toString());
+          }
+        }
+      }
+    }
+    // No kid, no match, or a single key: verify with the current signing key.
+    return keystore.getCertificate(getSigningKeyAlias()).getPublicKey();
+  }
+
+  private List<String> getVerificationKeyAliases() {
+    final List<String> aliases = config == null ? null : config.getSigningKeyAliases();
+    return (aliases == null || aliases.isEmpty()) ? Collections.singletonList(getSigningKeyAlias()) : aliases;
+  }
+
+  private static String extractKid(JWT token) {
+    try {
+      return JWSHeader.parse(token.getHeader()).getKeyID();
+    } catch (ParseException e) {
+      return null;
+    }
+  }
+
   private boolean verifyTokenUsingHMAC(JWT token) throws TokenServiceException {
     try {
       final JWSVerifier verifier = new MACVerifier(getHmacSecret());
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
index 0303150be..9a337146b 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
@@ -38,6 +38,7 @@ import java.nio.file.Paths;
 import java.security.KeyStore;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -825,4 +826,31 @@ public class GatewayConfigImplTest {
       System.clearProperty("KNOX_GATEWAY_CONF_DIR");
     }
   }
+
+  @Test
+  public void testSigningKeyAliases() {
+    GatewayConfigImpl config = new GatewayConfigImpl();
+
+    // Default: only the current signing key, so a single-key deployment is unchanged.
+    assertEquals(Collections.singletonList(config.getSigningKeyAlias()), config.getSigningKeyAliases());
+
+    // Additional aliases follow the current key, in order.
+    config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, "old-key-1, old-key-2");
+    List<String> aliases = config.getSigningKeyAliases();
+    assertEquals(3, aliases.size());
+    assertEquals(config.getSigningKeyAlias(), aliases.get(0));
+    assertTrue(aliases.contains("old-key-1"));
+    assertTrue(aliases.contains("old-key-2"));
+
+    // "none" disables additional aliases.
+    config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, "none");
+    assertEquals(Collections.singletonList(config.getSigningKeyAlias()), config.getSigningKeyAliases());
+
+    // The current alias is never published/checked twice, even if listed as additional.
+    config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, config.getSigningKeyAlias() + ", old-key-1");
+    aliases = config.getSigningKeyAliases();
+    assertEquals(2, aliases.size());
+    assertEquals(config.getSigningKeyAlias(), aliases.get(0));
+    assertTrue(aliases.contains("old-key-1"));
+  }
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
index a11db4f10..4682db8f5 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
@@ -18,15 +18,21 @@
 package org.apache.knox.gateway.services.token.impl;
 
 import java.io.File;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
 import java.security.Principal;
 import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Optional;
 
+import com.nimbusds.jose.crypto.RSASSASigner;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.KeystoreService;
 import org.apache.knox.gateway.services.security.MasterService;
 import org.apache.knox.gateway.services.security.impl.DefaultKeystoreService;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
@@ -34,6 +40,8 @@ import org.apache.knox.gateway.services.security.token.impl.JWTToken;
 import org.apache.knox.gateway.services.security.token.JWTokenAttributes;
 import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
 import org.apache.knox.gateway.services.security.token.TokenServiceException;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
+import org.apache.knox.gateway.util.X509CertificateUtil;
 
 import org.easymock.EasyMock;
 import org.junit.Test;
@@ -592,6 +600,62 @@ public class DefaultTokenAuthorityServiceTest {
     EasyMock.verify(config, ms, as);
   }
 
+  /**
+   * A token signed by a rotated-out key still verifies as long as that key's alias remains
+   * configured as an additional signing-key alias: verification selects the key by the token's
+   * {@code kid} header. Without that alias configured (single-key), the same token is rejected.
+   */
+  @Test
+  public void testVerifyTokenSelectsKeyByKidAcrossRotation() throws Exception {
+    final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+    kpg.initialize(2048);
+    final KeyPair currentPair = kpg.generateKeyPair();
+    final KeyPair rotatedOutPair = kpg.generateKeyPair();
+
+    final KeyStore signingKeystore = KeyStore.getInstance("JKS");
+    signingKeystore.load(null, null);
+    signingKeystore.setCertificateEntry("gateway-identity",
+        X509CertificateUtil.generateCertificate("CN=current", currentPair, 365, "SHA256withRSA"));
+    signingKeystore.setCertificateEntry("old-signing-key",
+        X509CertificateUtil.generateCertificate("CN=old", rotatedOutPair, 365, "SHA256withRSA"));
+
+    final KeystoreService ks = EasyMock.createNiceMock(KeystoreService.class);
+    EasyMock.expect(ks.getSigningKeystore()).andReturn(signingKeystore).anyTimes();
+    final AliasService as = EasyMock.createNiceMock(AliasService.class);
+
+    // A token signed by the rotated-out key, stamped with that key's kid (as issueToken would).
+    final String rotatedOutKid = TokenUtils.getThumbprint((RSAPublicKey) rotatedOutPair.getPublic(), "SHA-256");
+    final JWT token = new JWTToken(new JWTokenAttributesBuilder().setAlgorithm("RS256").setKid(rotatedOutKid)
+        .setAudiences(Collections.emptyList()).build());
+    token.sign(new RSASSASigner(rotatedOutPair.getPrivate(), true));
+
+    // (1) Rotated-out alias still configured -> kid selection picks it -> verifies.
+    final GatewayConfig multiKeyConfig = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(multiKeyConfig.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes();
+    EasyMock.expect(multiKeyConfig.getSigningKeyAliases())
+        .andReturn(Arrays.asList("gateway-identity", "old-signing-key")).anyTimes();
+    EasyMock.replay(ks, as, multiKeyConfig);
+
+    DefaultTokenAuthorityService ta = new DefaultTokenAuthorityService();
+    ta.setKeystoreService(ks);
+    ta.setAliasService(as);
+    ta.init(multiKeyConfig, new HashMap<>());
+    assertTrue("Token signed by a still-configured rotated key must verify", ta.verifyToken(token));
+
+    // (2) Only the current key configured (single-key) -> the rotated key's token is rejected.
+    final GatewayConfig singleKeyConfig = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(singleKeyConfig.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes();
+    EasyMock.expect(singleKeyConfig.getSigningKeyAliases())
+        .andReturn(Collections.singletonList("gateway-identity")).anyTimes();
+    EasyMock.replay(singleKeyConfig);
+
+    ta = new DefaultTokenAuthorityService();
+    ta.setKeystoreService(ks);
+    ta.setAliasService(as);
+    ta.init(singleKeyConfig, new HashMap<>());
+    assertFalse("Without the rotated key configured, its token must not verify", ta.verifyToken(token));
+  }
+
   /**
    * Test getSigningCertKid() function
    * @throws Exception
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
index 575caa06c..4d87e077d 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
@@ -19,6 +19,7 @@ package org.apache.knox.gateway.service.knoxtoken;
 
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.JWK;
 import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.KeyUse;
 import com.nimbusds.jose.jwk.RSAKey;
@@ -45,6 +46,9 @@ import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.cert.Certificate;
 import java.security.interfaces.RSAPublicKey;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 
@@ -80,23 +84,26 @@ public class JWKSResource {
   }
 
   private Response getJwks(final String keystore) {
-    JWKSet jwks;
     try {
-      final RSAPublicKey rsa = getPublicKey(keystore);
-      /* no public cert found, return empty set */
-      if(rsa == null) {
-        return Response.ok()
-            .entity(new JWKSet().toJSONObject().toString()).build();
+      // Publish one JWK per configured signing-key alias (current key first, then any additional
+      // verification keys). Each JWK carries its own 'kid' (SHA-256 thumbprint) so a verifier can
+      // select the right key across a key rotation. A single-key deployment yields exactly one JWK.
+      final List<JWK> keys = new ArrayList<>();
+      for (final String alias : getSigningKeyAliases()) {
+        final RSAPublicKey rsa = getPublicKey(keystore, alias);
+        /* no public cert for this alias, skip it */
+        if (rsa == null) {
+          continue;
+        }
+        final String kid = TokenUtils.getThumbprint(rsa, "SHA-256");
+        keys.add(new RSAKey.Builder(rsa)
+            .keyUse(KeyUse.SIGNATURE)
+            .algorithm(new JWSAlgorithm(this.signatureAlgorithm))
+            .keyID(kid)
+            .build());
       }
-
-      final String kid = TokenUtils.getThumbprint(rsa, "SHA-256");
-      final RSAKey.Builder builder = new RSAKey.Builder(rsa)
-          .keyUse(KeyUse.SIGNATURE)
-          .algorithm(new JWSAlgorithm(this.signatureAlgorithm))
-          .keyID(kid);
-
-      jwks = new JWKSet(builder.build());
-
+      return Response.ok()
+          .entity(new JWKSet(keys).toString()).type(MediaType.APPLICATION_JSON_TYPE).build();
     } catch (KeyStoreException | JOSEException e) {
       return Response.status(500)
           .entity("{\n  \"error\": \"" + e.toString() + "\"\n}\n").build();
@@ -105,19 +112,31 @@ public class JWKSResource {
           "{\n  \"error\": \"" + "keystore " + keystore + " could not be found."
               + "\"\n}\n").build();
     }
-    return Response.ok()
-            .entity(jwks.toString()).type(MediaType.APPLICATION_JSON_TYPE).build();
   }
 
   protected RSAPublicKey getPublicKey(final String keystore) throws KeystoreServiceException, KeyStoreException {
+    return getPublicKey(keystore, getSigningKeyAlias());
+  }
+
+  protected RSAPublicKey getPublicKey(final String keystore, final String alias) throws KeystoreServiceException, KeyStoreException {
     final KeyStore ks = keystoreService.getSigningKeystore(keystore);
-    final Certificate cert = ks.getCertificate(getSigningKeyAlias());
-    return (cert != null) ? (RSAPublicKey) cert.getPublicKey() : null;
+    final Certificate cert = ks.getCertificate(alias);
+    return (cert != null && cert.getPublicKey() instanceof RSAPublicKey) ? (RSAPublicKey) cert.getPublicKey() : null;
+  }
+
+  /**
+   * @return the configured signing-key aliases to publish, falling back to the single default
+   * signing key when none are configured (backward-compatible single-key behavior).
+   */
+  private List<String> getSigningKeyAliases() {
+    final GatewayConfig config = (GatewayConfig) context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
+    final List<String> aliases = (config == null) ? null : config.getSigningKeyAliases();
+    return (aliases == null || aliases.isEmpty()) ? Collections.singletonList(getSigningKeyAlias()) : aliases;
   }
 
   private String getSigningKeyAlias() {
     final GatewayConfig config = (GatewayConfig) context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
-    final String alias = config.getSigningKeyAlias();
+    final String alias = (config == null) ? null : config.getSigningKeyAlias();
     return (alias == null) ? GatewayConfig.DEFAULT_SIGNING_KEY_ALIAS : alias;
   }
 
diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
index 966ca8554..0c27238ce 100644
--- a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
+++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
@@ -26,6 +26,7 @@ import java.security.PublicKey;
 import java.security.cert.Certificate;
 import java.security.interfaces.RSAPrivateKey;
 import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
 import java.util.Collections;
 
 import javax.servlet.ServletContext;
@@ -37,6 +38,7 @@ import org.apache.knox.gateway.services.GatewayServices;
 import org.apache.knox.gateway.services.ServiceType;
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.KeystoreService;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
 import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
@@ -133,6 +135,61 @@ public class JWKSResourceTest {
         testToken.verify(verifier));
   }
 
+  /**
+   * When more than one signing-key alias is configured, the JWKS endpoint must publish one JWK per
+   * alias, each carrying its own 'kid' (SHA-256 thumbprint), so verifiers can select the right key
+   * across a manual key rotation.
+   */
+  @Test
+  public void testMultipleKeysPublished() throws Exception {
+    /* a second, distinct signing key that stands in for a rotated-out key */
+    final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+    kpg.initialize(2048);
+    final RSAPublicKey previousPublicKey = (RSAPublicKey) kpg.generateKeyPair().getPublic();
+
+    final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    final GatewayServices svcs = EasyMock.createNiceMock(GatewayServices.class);
+    final KeystoreService ks = EasyMock.createNiceMock(KeystoreService.class);
+    final KeyStoreSpi keyStoreSpi = EasyMock.createNiceMock(KeyStoreSpi.class);
+    final KeyStore keystore = new KeyStoreMock(keyStoreSpi, null, "test");
+    keystore.load(null);
+    EasyMock.expect(ks.getSigningKeystore(null)).andReturn(keystore).anyTimes();
+
+    /* distinct cert per alias: 'gateway-identity' (current) and 'old-signing-key' (rotated-out) */
+    final Certificate currentCert = EasyMock.createNiceMock(Certificate.class);
+    final Certificate previousCert = EasyMock.createNiceMock(Certificate.class);
+    EasyMock.expect(keyStoreSpi.engineGetCertificate("gateway-identity")).andReturn(currentCert).anyTimes();
+    EasyMock.expect(keyStoreSpi.engineGetCertificate("old-signing-key")).andReturn(previousCert).anyTimes();
+    EasyMock.expect(currentCert.getPublicKey()).andReturn(publicKey).anyTimes();
+    EasyMock.expect(previousCert.getPublicKey()).andReturn(previousPublicKey).anyTimes();
+
+    EasyMock.expect(svcs.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(ks).anyTimes();
+    EasyMock.expect(svcs.getService(ServiceType.ALIAS_SERVICE)).andReturn(EasyMock.createNiceMock(AliasService.class)).anyTimes();
+    EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(svcs).anyTimes();
+    final GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(config.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes();
+    EasyMock.expect(config.getSigningKeyAliases()).andReturn(Arrays.asList("gateway-identity", "old-signing-key")).anyTimes();
+    EasyMock.expect(ctx.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(config).anyTimes();
+    EasyMock.replay(ctx, req, svcs, ks, keyStoreSpi, currentCert, previousCert, config);
+
+    final JWKSResource jwksResource = new JWKSResource();
+    jwksResource.context = ctx;
+    jwksResource.request = req;
+    jwksResource.init();
+    final Response retResponse = jwksResource.getJwksResponse();
+    Assert.assertEquals(Response.Status.OK.getStatusCode(), retResponse.getStatus());
+
+    final JWKSet jwks = JWKSet.parse(retResponse.getEntity().toString());
+    Assert.assertEquals("Expected one JWK per configured alias", 2, jwks.getKeys().size());
+    /* both keys are present and addressable by their own kid */
+    final String currentKid = TokenUtils.getThumbprint(publicKey, "SHA-256");
+    final String previousKid = TokenUtils.getThumbprint(previousPublicKey, "SHA-256");
+    Assert.assertNotEquals("The two keys must have distinct kids", currentKid, previousKid);
+    Assert.assertNotNull("Current key not published under its kid", jwks.getKeyByKeyId(currentKid));
+    Assert.assertNotNull("Rotated-out key not published under its kid", jwks.getKeyByKeyId(previousKid));
+  }
+
   private JWT getTestToken(final String algorithm) {
     String[] claimArray = new String[6];
     claimArray[0] = "KNOXSSO";
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
index a416d1392..dbefeea68 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
@@ -21,6 +21,7 @@ import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
 import java.security.KeyStore;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -72,6 +73,10 @@ public interface GatewayConfig {
   String SIGNING_KEYSTORE_PASSWORD_ALIAS = "gateway.signing.keystore.password.alias";
   String SIGNING_KEYSTORE_TYPE = "gateway.signing.keystore.type";
   String SIGNING_KEY_ALIAS = "gateway.signing.key.alias";
+  // Comma-separated list of additional signing-keystore aliases whose public keys are published on
+  // the JWKS endpoint and accepted (selected by 'kid') when verifying gateway-signed JWTs. Lets an
+  // operator retain a previous key across a manual key rotation so already-issued tokens still verify.
+  String SIGNING_KEY_ALIASES_ADDITIONAL = "gateway.signing.key.aliases.additional";
   String SIGNING_KEY_PASSPHRASE_ALIAS = "gateway.signing.key.passphrase.alias";
   String DEFAULT_SIGNING_KEYSTORE_PASSWORD_ALIAS = "signing.keystore.password";
   String DEFAULT_SIGNING_KEYSTORE_TYPE = KeyStore.getDefaultType();
@@ -463,6 +468,21 @@ public interface GatewayConfig {
    */
   String getSigningKeyPassphraseAlias();
 
+  /**
+   * Returns the ordered list of signing-keystore aliases whose public keys the gateway publishes on
+   * the JWKS endpoint and accepts (selected by {@code kid}) when verifying gateway-signed JWTs. The
+   * current signing key ({@link #getSigningKeyAlias()}) is always first; any additional
+   * verification-only keys (e.g. a previous key retained across a manual key rotation) follow.
+   * <p>
+   * Implementations that do not support additional keys return just the current signing key, so a
+   * single-key deployment behaves exactly as before.
+   *
+   * @return the current signing key alias followed by any additional verification key aliases
+   */
+  default List<String> getSigningKeyAliases() {
+    return getSigningKeyAlias() == null ? Collections.emptyList() : Collections.singletonList(getSigningKeyAlias());
+  }
+
 
   List<String> getGlobalRulesServices();
 


(knox) 08/37: KNOX-3390: Corrected JUnit test coverage for max issuers (#1326)

Posted by sm...@apache.org.
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 f920ec98606bfd6a8876814491110a9b1502e403
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Fri Jul 24 20:31:32 2026 +0200

    KNOX-3390: Corrected JUnit test coverage for max issuers (#1326)
---
 .../trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java  | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
index bccd9274a..e74e476cf 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
@@ -38,8 +38,8 @@ import java.util.List;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 public class JdbcTrustedOidcIssuerServiceTest {
 
@@ -217,7 +217,7 @@ public class JdbcTrustedOidcIssuerServiceTest {
     assertTrue(service.list().isEmpty());
   }
 
-  @Test(expected = IllegalStateException.class)
+  @Test
   public void testMaxTrustedIssuers() throws ServiceLifecycleException {
     final GatewayConfig limitedConfig = EasyMock.createNiceMock(GatewayConfig.class);
     EasyMock.expect(limitedConfig.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
@@ -235,9 +235,11 @@ public class JdbcTrustedOidcIssuerServiceTest {
     limitedService.register(issuer("https://b.example.com", false));
     assertEquals("Second registration must succeed", 2, limitedService.list().size());
 
-    // this one should fail (see expected error on the test annotation)
-    limitedService.register(issuer("https://c.example.com", false));
-    fail("Expected IllegalStateException when exceeding max issuers limit");
+    assertThrows(IllegalStateException.class,
+            () -> limitedService.register(issuer("https://c.example.com", false)));
+
+    assertEquals("Prior registrations must be unaffected by the rejected call",
+            2, limitedService.list().size());
   }
 
   @Test


(knox) 32/37: KNOX-3414: complete OIDC discovery metadata for OAuth/MCP client integration

Posted by sm...@apache.org.
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 7341b1b89031c6affe302de5d876ae3476dbca3d
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 00:44:00 2026 +0200

    KNOX-3414: complete OIDC discovery metadata for OAuth/MCP client integration
    
    The .well-known/openid-configuration document was missing a REQUIRED field
    and several fields clients rely on to integrate without out-of-band config.
    
    - subject_types_supported: ["public"] -- REQUIRED by OpenID Connect Discovery
      1.0; its absence makes the document non-conformant. Knox derives 'sub' as a
      deterministic UUIDv5 over a fixed namespace and the user identity (the same
      for every client), so the subject identifier type is "public", not pairwise.
    - registration_endpoint -> the /client dynamic client registration resource
      KnoxIDF already serves. This is the spec-sanctioned path for MCP clients that
      cannot pre-register.
    - token_endpoint_auth_methods_supported: ["client_secret_post", "none"] --
      matches what the token endpoint actually reads: confidential clients send
      client_secret in request params (client_secret_post), public clients use PKCE
      with no secret ("none"). client_secret_basic is intentionally excluded because
      the endpoint does not read HTTP Basic credentials.
    - client_id_metadata_document_supported: false -- stated explicitly (the CIMD
      draft default when absent) so MCP clients use registration_endpoint rather
      than an HTTPS-URL client_id; Knox does not resolve URL client_ids to fetched
      metadata documents.
    
    Test: DiscoveryResourceMetadataTest asserts the added fields and that neither
    client_secret_basic nor CIMD support is falsely advertised.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/DiscoveryResource.java | 17 ++++++
 .../knoxidf/DiscoveryResourceMetadataTest.java     | 69 ++++++++++++++++++++++
 2 files changed, 86 insertions(+)

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 e55ea817d..26b6a8cbe 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
@@ -66,8 +66,25 @@ public class DiscoveryResource {
         }
         config.put("token_endpoint", tokenEndpoint);
         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("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
+        // a fixed namespace and the user identity -- the same for every client -- so the subject
+        // identifier type is "public" (not "pairwise").
+        config.put("subject_types_supported", new String[]{"public"});
+        // The token endpoint reads client credentials only from request parameters (no HTTP Basic):
+        // confidential clients send client_secret in the body (client_secret_post); public clients
+        // authenticate with PKCE and no secret ("none"). client_secret_basic is intentionally absent
+        // because it is not honored.
+        config.put("token_endpoint_auth_methods_supported", new String[]{"client_secret_post", "none"});
+        // Explicitly false: Knox does not resolve an HTTPS-URL client_id to a fetched Client ID
+        // Metadata Document (OAuth CIMD draft, referenced by MCP). This is the spec default when the
+        // field is absent, but stating it tells MCP clients to use dynamic client registration
+        // (registration_endpoint) rather than a URL client_id. Flip to true only if CIMD is implemented.
+        config.put("client_id_metadata_document_supported", Boolean.FALSE);
         config.put("grant_types_supported", new String[]{KnoxIDFConstants.AUTH_CODE, KnoxIDFConstants.REFRESH_TOKEN});
         config.put("scopes_supported", KnoxIDFConstants.DEFAULT_SCOPES);
         config.put("id_token_signing_alg_values_supported", new String[]{"RS256"});
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java
new file mode 100644
index 000000000..4f5c90689
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.net.URI;
+
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+/**
+ * Verifies the discovery document carries required/expected OIDC provider metadata.
+ * subject_types_supported is REQUIRED by OpenID Connect Discovery 1.0; a strict client or
+ * conformance validator rejects a document that omits it. registration_endpoint must point at the
+ * dynamic client registration resource that KnoxIDF actually serves.
+ */
+public class DiscoveryResourceMetadataTest {
+
+  @Test
+  public void testAdvertisesSubjectTypesAndRegistrationEndpoint() {
+    final UriInfo uriInfo = EasyMock.createNiceMock(UriInfo.class);
+    EasyMock.expect(uriInfo.getBaseUri()).andReturn(URI.create("https://knox:8443/gateway/knoxidf/")).anyTimes();
+    EasyMock.replay(uriInfo);
+
+    final Response response = new DiscoveryResource().getConfig(uriInfo);
+    final String body = String.valueOf(response.getEntity());
+
+    // subject_types_supported is REQUIRED; Knox uses a shared (non-pairwise) subject -> "public".
+    assertTrue("subject_types_supported must be present (REQUIRED by OIDC Discovery).",
+        body.contains("subject_types_supported"));
+    assertTrue("subject_types_supported must advertise 'public'.",
+        body.contains("\"public\""));
+
+    // registration_endpoint must resolve to the dynamic client registration resource.
+    assertTrue("registration_endpoint must point at the /client registration resource.",
+        body.contains("registration_endpoint") && body.contains(RegistrationResource.RESOURCE_PATH));
+
+    // The token endpoint authenticates clients via body params only: client_secret_post + none (PKCE).
+    assertTrue("token_endpoint_auth_methods_supported must advertise client_secret_post and none.",
+        body.contains("token_endpoint_auth_methods_supported")
+            && body.contains("client_secret_post") && body.contains("\"none\""));
+    // It must NOT claim HTTP Basic client auth, which the token endpoint does not read.
+    assertFalse("Discovery must not advertise client_secret_basic, which is not honored.",
+        body.contains("client_secret_basic"));
+
+    // CIMD is not implemented, so it must be advertised explicitly as false (never true).
+    assertTrue("client_id_metadata_document_supported must be present and false.",
+        body.contains("\"client_id_metadata_document_supported\":false"));
+  }
+}


(knox) 17/37: KNOX-3414: Tier 3 hardening + Tier 4 polish for the OIDC provider

Posted by sm...@apache.org.
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 7802c3c65cf88cd0ac97c8bf96770b62e6e10e17
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Mon Aug 10 21:55:15 2026 +0200

    KNOX-3414: Tier 3 hardening + Tier 4 polish for the OIDC provider
    
    Tier 3 (hardening):
    - Registration redirect_uris require HTTPS (plain HTTP only for loopback, RFC 8252)
    - XSS: escape unknown scopes in AuthConsentServlet; rebuild knoxauth.js OP links
      via the DOM API instead of raw HTML / inline onclick interpolation
    - URL-encode clientId and federated-OP redirect params in AuthorizeResource / KnoxIDFUtils
    - Single-use consent/federation state: add KnoxIDFArtifactStore.remove() and invalidate
      state after use in authCallback and consentAccepted
    - Reject disabled refresh tokens (isEnabled check) in TokenResource
    - Remove hardcoded LDAP "admin-password" fallback; fail fast without a configured alias
    - DiscoveryResource: literal String.replace instead of regex replaceAll for topology name
    - error() maps OAuth error codes to correct HTTP status per RFC 6749 5.2 (no longer always 401)
    - RedirectToUrlFilter: treat a blank fedOpSid the same as absent
    
    Tier 4 (polish):
    - Fix public-API typos: EmptyFederatedIdentityService, BASE_RESOURCE_PATH
    - Make ALLOWED_RESPONSE_TYPES / DEFAULT_SCOPES immutable; copy at mutating call sites
    - Drop nonce from the UserInfo response (id_token only)
    - Document the KnoxIDFArtifactStore ttl*2 grace window
    
    Tests: KnoxIDFUtilsErrorStatusTest, RegistrationRedirectUriPolicyTest, KnoxIDFArtifactStoreTest
    (+18); TokenResourceAuthCodeReplayTest updated 401->400. All affected modules green.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../applications/knoxauth/app/js/knoxauth.js       | 14 ++--
 .../knox/gateway/filter/RedirectToUrlFilter.java   |  5 +-
 .../factory/FederatedIdentityServiceFactory.java   | 10 +--
 ...ice.java => EmptyFederatedIdentityService.java} |  2 +-
 .../service/knoxidf/AuthConsentServlet.java        |  6 +-
 .../gateway/service/knoxidf/AuthorizeResource.java | 20 ++++--
 .../gateway/service/knoxidf/DiscoveryResource.java | 10 +--
 .../knox/gateway/service/knoxidf/JwksResource.java |  4 +-
 .../service/knoxidf/RegistrationResource.java      | 33 +++++++--
 .../gateway/service/knoxidf/TokenResource.java     | 14 ++--
 .../gateway/service/knoxidf/UserInfoResource.java  | 11 ++-
 .../knoxidf/userparams/LdapUserParamsProvider.java | 11 ++-
 .../knoxidf/KnoxIDFUtilsErrorStatusTest.java       | 83 ++++++++++++++++++++++
 .../knoxidf/RegistrationRedirectUriPolicyTest.java | 76 ++++++++++++++++++++
 .../knoxidf/TokenResourceAuthCodeReplayTest.java   |  2 +-
 .../gateway/util/knoxidf/KnoxIDFArtifactStore.java |  9 +++
 .../gateway/util/knoxidf/KnoxIDFConstants.java     | 11 +--
 .../knox/gateway/util/knoxidf/KnoxIDFUtils.java    | 53 ++++++++++++--
 .../util/knoxidf/KnoxIDFArtifactStoreTest.java     | 63 ++++++++++++++++
 19 files changed, 381 insertions(+), 56 deletions(-)

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 5a304a556..329ad930c 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
@@ -74,12 +74,14 @@ var loadFederatedOpLinks = function() {
 	}
 
 	ops.forEach(op => {
-		container.append(`
-            <div class="fed-op-btn" onclick="loginWithOp('${op}')">
-                <span class="fed-op-icon">🌐</span>
-                <span class="fed-op-label">Continue with ${op}</span>
-            </div>
-        `);
+		// Build the element via the DOM API and bind the handler in JS rather than interpolating
+		// the (attacker-controllable) op name into an HTML string / inline onclick attribute.
+		// .text() escapes the label; the click closure captures op without string injection.
+		const btn = $('<div class="fed-op-btn"></div>');
+		$('<span class="fed-op-icon"></span>').text("🌐").appendTo(btn);
+		$('<span class="fed-op-label"></span>').text("Continue with " + op).appendTo(btn);
+		btn.on("click", function() { loginWithOp(op); });
+		container.append(btn);
 	});
 };
 
diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java
index 7501dbe4b..bffc5049a 100644
--- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java
+++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java
@@ -27,6 +27,7 @@ import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
+import org.apache.commons.lang3.StringUtils;
 import org.apache.knox.gateway.config.GatewayConfig;
 
 public class RedirectToUrlFilter extends AbstractGatewayFilter {
@@ -47,7 +48,9 @@ public class RedirectToUrlFilter extends AbstractGatewayFilter {
 
   @Override
   protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
-    if (redirectUrl != null && request.getHeader("Authorization") == null && request.getParameter("fedOpSid") == null) {
+    // Treat a blank fedOpSid the same as absent: a bare "?fedOpSid=" must not suppress the redirect.
+    // (Downstream authentication still runs; this only prevents an empty param from skipping it.)
+    if (redirectUrl != null && request.getHeader("Authorization") == null && StringUtils.isBlank(request.getParameter("fedOpSid"))) {
       response.sendRedirect(redirectUrl + getOriginalQueryString(request));
     }
     chain.doFilter(request, response);
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
index f78f96af6..7ffe009fa 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
@@ -23,7 +23,7 @@ import org.apache.knox.gateway.services.GatewayServices;
 import org.apache.knox.gateway.services.Service;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.ServiceType;
-import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentitityService;
+import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentityService;
 import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
 import org.apache.knox.gateway.services.knoxidf.federation.JdbcFederatedIdentityService;
 import org.apache.knox.gateway.services.topology.TopologyService;
@@ -36,7 +36,7 @@ import java.util.Map;
 public class FederatedIdentityServiceFactory extends AbstractServiceFactory {
 
     private static final GatewayMessages LOG = MessagesFactory.get(GatewayMessages.class);
-    private static final String DEFAULT_IMPLEMENTATION = EmptyFederatedIdentitityService.class.getName();
+    private static final String DEFAULT_IMPLEMENTATION = EmptyFederatedIdentityService.class.getName();
 
     @Override
     protected Service createService(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map<String, String> options, String implementation)
@@ -52,8 +52,8 @@ public class FederatedIdentityServiceFactory extends AbstractServiceFactory {
 
         FederatedIdentityService service = null;
         if (shouldCreateService(implementationToUse)) {
-            if (matchesImplementation(implementationToUse, EmptyFederatedIdentitityService.class, true)) {
-                service = new EmptyFederatedIdentitityService();
+            if (matchesImplementation(implementationToUse, EmptyFederatedIdentityService.class, true)) {
+                service = new EmptyFederatedIdentityService();
             } else if (matchesImplementation(implementationToUse, JdbcFederatedIdentityService.class)) {
                 try {
                     try {
@@ -62,7 +62,7 @@ public class FederatedIdentityServiceFactory extends AbstractServiceFactory {
                         service.init(gatewayConfig, options);
                     } catch (ServiceLifecycleException e) {
                         LOG.errorInitializingService(implementationToUse, e.getMessage(), e);
-                        service =  new EmptyFederatedIdentitityService();
+                        service =  new EmptyFederatedIdentityService();
                     }
                 } catch (Exception e) {
                     throw new ServiceLifecycleException("Error while creating Federated Identity Service: " + e, e);
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentityService.java
similarity index 95%
rename from gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java
rename to gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentityService.java
index 8ce9e550e..d73cd0c8d 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentityService.java
@@ -22,7 +22,7 @@ import org.apache.knox.gateway.services.ServiceLifecycleException;
 import java.util.Map;
 import java.util.Optional;
 
-public class EmptyFederatedIdentitityService implements FederatedIdentityService {
+public class EmptyFederatedIdentityService implements FederatedIdentityService {
     @Override
     public void addFederatedIdentity(FederatedIdentity identity) {
     }
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java
index 6200edc83..211c9fec6 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java
@@ -16,6 +16,8 @@
  */
 package org.apache.knox.gateway.service.knoxidf;
 
+import org.apache.commons.text.StringEscapeUtils;
+
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -114,7 +116,9 @@ public class AuthConsentServlet extends HttpServlet {
             case "calendar.write":
                 return "Modify your calendar events";
             default:
-                return scope;
+                // Unknown scopes are echoed into the HTML consent page. Escape them so an
+                // attacker-influenced scope value cannot inject markup (defense in depth).
+                return StringEscapeUtils.escapeHtml4(scope);
         }
     }
 
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index d778aa7e1..b27c7f9d8 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -87,7 +87,7 @@ import java.util.stream.Collectors;
 import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
 import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.ALLOWED_SCOPES;
-import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE;
@@ -107,7 +107,7 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
 
 @Path(AuthorizeResource.RESOURCE_PATH)
 public class AuthorizeResource extends PasscodeTokenResourceBase {
-    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/authorize";
+    static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/authorize";
     private static final UUID KNOX_NAMESPACE = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
     private static final NameBasedGenerator UUID_V5 = Generators.nameBasedGenerator(KNOX_NAMESPACE);
     public static final Set<String> ALLOWED_CLAIMS = Set.of("preferred_username", "email", "email_verified",
@@ -166,7 +166,8 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
                                String codeChallenge,
                                String codeChallengeMethod) {
         final String subject = SubjectUtils.getCurrentEffectivePrincipalName();
-        final Set<String> requestedScopes = StringUtils.isBlank(scope) ? DEFAULT_SCOPES : new HashSet<>(Arrays.asList(scope.split("\\s+")));
+        // DEFAULT_SCOPES is an ImmutableSet; copy it into a mutable set so downstream mutation is safe.
+        final Set<String> requestedScopes = StringUtils.isBlank(scope) ? new HashSet<>(DEFAULT_SCOPES) : new HashSet<>(Arrays.asList(scope.split("\\s+")));
         final AuthorizeRequestMetadata authorizeRequestMetadata = new AuthorizeRequestMetadata(clientId, subject, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod);
         final Response verificationErrorResponse = verifyParams(authorizeRequestMetadata);
         if (verificationErrorResponse != null) {
@@ -180,8 +181,11 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
                 final String consentAuthState = UUID.randomUUID().toString();
                 authorizeRequestMetadataStore.put(consentAuthState, authorizeRequestMetadata);
                 final String baseUri = servletContext.getContextPath() + "/authConsent";
+                // Every value placed into the consent redirect's query string must be percent-encoded;
+                // a client_id containing '&', '=' or '#' would otherwise split or corrupt the URL.
+                final String clientIdParam = URLEncoder.encode(clientId, StandardCharsets.UTF_8);
                 final String scopeParam = URLEncoder.encode(authorizeRequestMetadata.getJoinedRequestedScopes(), StandardCharsets.UTF_8);
-                final String redirect = String.format(Locale.US, "%s?client_id=%s&state=%s&scope=%s", baseUri, clientId, consentAuthState, scopeParam);
+                final String redirect = String.format(Locale.US, "%s?client_id=%s&state=%s&scope=%s", baseUri, clientIdParam, consentAuthState, scopeParam);
                 return Response.seeOther(java.net.URI.create(redirect)).build();
             }
         }
@@ -277,6 +281,10 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         if (federatedOpConfiguration == null) {
             return error("invalid_request", "No federated OP configuration available for the request");
         }
+        // The federated callback state is single-use: invalidate it in both stores now that it has
+        // been validated and captured, so a replayed callback with the same state is rejected.
+        authorizeRequestMetadataStore.remove(state);
+        federatedOpConfigurationStore.remove(state);
         final Pair<String, String> federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
         if (StringUtils.isBlank(federatedTokens.getLeft())) {
             return error("invalid_request", "Federated OP did not return an id_token");
@@ -297,8 +305,10 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         final String state = request.getParameter(STATE);
         final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state);
         if (authorizeRequestMetadata == null) {
-            return error("Consent cannot be accepted", "Invalid state");
+            return error("invalid_request", "Invalid state");
         }
+        // Single-use consent state: invalidate it so the accepted-consent redirect cannot be replayed.
+        authorizeRequestMetadataStore.remove(state);
         markConsentAccepted(authorizeRequestMetadata);
         return authorize(authorizeRequestMetadata.getResponseType(),
                 authorizeRequestMetadata.getClientId(),
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 ce895549d..eb6584f3e 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
@@ -32,10 +32,10 @@ import javax.ws.rs.core.UriInfo;
 import java.util.HashMap;
 import java.util.Map;
 
-import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_EXCHANGE_TOPOLOGY_NAME;
 
-@Path(BASE_RESORCE_PATH + "/.well-known/openid-configuration")
+@Path(BASE_RESOURCE_PATH + "/.well-known/openid-configuration")
 @Produces(MediaType.APPLICATION_JSON)
 public class DiscoveryResource {
     private String currentTopologyName;
@@ -59,8 +59,10 @@ public class DiscoveryResource {
         String tokenEndpoint = baseUrl + TokenResource.RESOURCE_PATH;
         String userInfoEndpoint = baseUrl + UserInfoResource.RESOURCE_PATH;
         if (tokenExchangeTopologyName != null) {
-            tokenEndpoint = tokenEndpoint.replaceAll(currentTopologyName, tokenExchangeTopologyName);
-            userInfoEndpoint = userInfoEndpoint.replaceAll(currentTopologyName, tokenExchangeTopologyName);
+            // Literal substitution: the topology name is data, not a regex. replaceAll would treat
+            // any regex metacharacter in the topology name as a pattern.
+            tokenEndpoint = tokenEndpoint.replace(currentTopologyName, tokenExchangeTopologyName);
+            userInfoEndpoint = userInfoEndpoint.replace(currentTopologyName, tokenExchangeTopologyName);
         }
         config.put("token_endpoint", tokenEndpoint);
         config.put("userinfo_endpoint", userInfoEndpoint);
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java
index 47f802b04..1dbca3c8e 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java
@@ -24,12 +24,12 @@ import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 
-import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH;
 
 @Path(JwksResource.RESOURCE_PATH)
 @Produces(MediaType.APPLICATION_JSON)
 public class JwksResource extends JWKSResource {
-    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/jwks";
+    static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/jwks";
 
     @GET
     public Response getKeys() {
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 ed3ad7411..bd7dedff2 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,7 +43,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 
-import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.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.DEFAULT_SCOPES;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
@@ -52,7 +52,7 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
 @RequestScoped //this is important because redirectUris/allowedScopes are part of the state of this class
 public class RegistrationResource extends ClientCredentialsResource {
 
-    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/client";
+    static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/client";
     private static final String ANONYMOUS_PRINCIPAL = "anonymous";
 
     private List<String> redirectUris;
@@ -125,6 +125,12 @@ public class RegistrationResource extends ClientCredentialsResource {
     }
 
     private Response verifyRedirectUris() {
+        return verifyRedirectUris(redirectUris);
+    }
+
+    // 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) {
         if (redirectUris == null || redirectUris.isEmpty()) {
             return error("invalid_request", "redirect_uris must be provided");
         }
@@ -137,16 +143,20 @@ public class RegistrationResource extends ClientCredentialsResource {
                 return error("invalid_request", "Invalid redirect URI: " + uriStr);
             }
 
-            // Scheme check
-            if (!"https".equalsIgnoreCase(uri.getScheme()) && !"http".equalsIgnoreCase(uri.getScheme())) {
-                return error("invalid_request", "Redirect URI must use HTTPS or HTTP as scheme: " + uriStr);
-            }
-
             // Host check (no wildcard allowed)
             if (uri.getHost() == null || uri.getHost().contains("*")) {
                 return error("invalid_request", "Wildcard not allowed in host: " + uriStr);
             }
 
+            // Scheme check: require HTTPS per RFC 8252, allowing plain HTTP only for loopback
+            // (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());
+            if (!https && !loopbackHttp) {
+                return error("invalid_request", "Redirect URI must use HTTPS (plain HTTP allowed only for localhost): " + uriStr);
+            }
+
             // Path wildcard check
             String path = uri.getPath();
             if (path != null && path.contains("*") && !path.endsWith("*")) {
@@ -162,6 +172,15 @@ public class RegistrationResource extends ClientCredentialsResource {
         return null;
     }
 
+    private static boolean isLoopbackHost(String host) {
+        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);
+    }
+
     @Override
     protected void addArbitraryTokenMetadata(TokenMetadata tokenMetadata) {
         tokenMetadata.add("redirect_uris", getRedirectUris());
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 540df06f6..513b275c3 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
@@ -61,7 +61,7 @@ import java.util.Map;
 import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
 import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.AUTH_CODE;
-import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE;
@@ -80,7 +80,7 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
 @Path(TokenResource.RESOURCE_PATH)
 @Produces(MediaType.APPLICATION_JSON)
 public class TokenResource extends PasscodeTokenResourceBase {
-    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/token";
+    static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/token";
 
     // Per-request stash for the auth-code TokenMetadata read during validation. The code is
     // atomically consumed (deleted) BEFORE token issuance to close the replay window, so the
@@ -228,7 +228,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
         } catch (UnknownTokenException e) {
             return error("invalid_grant", "Unknown refresh_token");
         } catch (RefreshTokenValidationError e) {
-            return error("Refresh token validation error", e.getMessage());
+            return error("invalid_grant", e.getMessage());
         }
 
     }
@@ -248,6 +248,12 @@ public class TokenResource extends PasscodeTokenResourceBase {
             throw new RefreshTokenValidationError("Invalid grant: invalid refresh_token");
         }
 
+        // A refresh token that has been administratively disabled (revoked) must not mint new tokens,
+        // even if it has not yet expired.
+        if (!refreshTokenMetadata.isEnabled()) {
+            throw new RefreshTokenValidationError("Invalid grant: refresh_token disabled");
+        }
+
         if (tokenStateService.getTokenExpiration(refreshTokenId) <= System.currentTimeMillis()) {
             throw new RefreshTokenValidationError("Invalid grant: Refresh token expired");
         }
@@ -268,7 +274,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
         try {
             authCodeMetadata = validateAuthCode(code, redirectUri);
         } catch (AuthTokenValidationError e) {
-            return error("Auth code validation error", e.getMessage());
+            return error("invalid_grant", e.getMessage());
         }
 
         // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
index c837605ae..b4fccedd0 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
@@ -43,7 +43,7 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.stream.Collectors;
 
-import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE_ATTRIBUTE;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_ID_ATTRIBUTE;
 import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
@@ -53,7 +53,7 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
 @Produces(MediaType.APPLICATION_JSON)
 public class UserInfoResource {
 
-    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/userinfo";
+    static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/userinfo";
     private UserParamsProvider userParamsProvider;
 
     @Context
@@ -117,11 +117,8 @@ public class UserInfoResource {
             claims.put("federated_sub", federatedIdentity.getExternalSubject());
             claims.put("federated_iss", federatedIdentity.getExternalIssuer());
 
-            // Add nonce if available
-            String nonce = tokenMetadata.getMetadata("nonce");
-            if (StringUtils.isNotBlank(nonce)) {
-                claims.put("nonce", nonce);
-            }
+            // Note: nonce is deliberately NOT returned here. Per OIDC it belongs in the id_token
+            // only; echoing it from the UserInfo endpoint is a spec violation and serves no purpose.
 
             userInfo.putAll(claims);
         } else {
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java
index 080cc32de..10791d7be 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java
@@ -52,7 +52,6 @@ public class LdapUserParamsProvider implements UserParamsProvider {
     private static final String DEFAULT_BASE_DN = "dc=hadoop,dc=apache,dc=org";
     private static final String DEFAULT_USER_DN_TEMPLATE = "uid=%s,ou=people," + DEFAULT_BASE_DN;
     private static final String DEFAULT_SYSTEM_USER = "uid=admin,ou=people," + DEFAULT_BASE_DN;
-    private static final String DEFAULT_SYSTEM_PASSWORD = "admin-password";
 
     private static final String[] ATTRIBUTES = {"cn", "sn", "givenName", "mail"};
 
@@ -80,9 +79,11 @@ public class LdapUserParamsProvider implements UserParamsProvider {
         final AliasService aliasService = services.getService(ServiceType.ALIAS_SERVICE);
         try {
             final char[] systemPassword = aliasService.getPasswordFromAliasForGateway(LDAP_SYSTEM_PASSWORD_ALIAS);
-            return systemPassword == null ? DEFAULT_SYSTEM_PASSWORD : new String(systemPassword);
+            // No hardcoded fallback: if the alias is absent/unresolvable, return null so the LDAP
+            // bind fails fast rather than silently binding with a well-known demo password.
+            return systemPassword == null ? null : new String(systemPassword);
         } catch (AliasServiceException e) {
-            return DEFAULT_SYSTEM_PASSWORD;
+            return null;
         }
     }
 
@@ -179,6 +180,10 @@ public class LdapUserParamsProvider implements UserParamsProvider {
     }
 
     private LdapContext createSystemContext() throws Exception {
+        if (ldapSystemPassword == null) {
+            throw new IllegalStateException("No LDAP system password configured. Set the '"
+                    + LDAP_SYSTEM_PASSWORD_ALIAS + "' alias; there is no default password.");
+        }
         Hashtable<String, Object> env = new Hashtable<>();
         env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
         env.put(Context.PROVIDER_URL, ldapUrl);
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java
new file mode 100644
index 000000000..d7cd7fb55
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils;
+import org.junit.Test;
+
+/**
+ * Verifies {@link KnoxIDFUtils#error(String, String)} maps each OAuth 2.0 error code to the HTTP
+ * status RFC 6749 §5.2 prescribes, rather than the previous always-401 behavior.
+ */
+public class KnoxIDFUtilsErrorStatusTest {
+
+  private static int statusOf(String error) {
+    return KnoxIDFUtils.error(error, "desc").getStatus();
+  }
+
+  @Test
+  public void testInvalidClientIsUnauthorized() {
+    assertEquals(401, statusOf("invalid_client"));
+  }
+
+  @Test
+  public void testAccessDeniedIsForbidden() {
+    assertEquals(403, statusOf("access_denied"));
+  }
+
+  @Test
+  public void testServerErrorIs500() {
+    assertEquals(500, statusOf("server_error"));
+  }
+
+  @Test
+  public void testTemporarilyUnavailableIs503() {
+    assertEquals(503, statusOf("temporarily_unavailable"));
+  }
+
+  @Test
+  public void testProtocolErrorsDefaultToBadRequest() {
+    for (final String error : new String[]{"invalid_request", "invalid_grant", "invalid_scope",
+        "unsupported_grant_type", "unsupported_response_type", "unauthorized_client"}) {
+      assertEquals("Expected 400 for " + error, 400, statusOf(error));
+    }
+  }
+
+  @Test
+  public void testUnknownAndNullErrorDefaultToBadRequest() {
+    assertEquals(400, statusOf("something_unexpected"));
+    assertEquals(400, statusOf(null));
+  }
+
+  @Test
+  public void testExplicitStatusOverloadWins() {
+    final Response response = KnoxIDFUtils.error("invalid_request", "desc", Response.Status.CONFLICT);
+    assertEquals(409, response.getStatus());
+  }
+
+  @Test
+  public void testBodyCarriesErrorCodeAndDescription() {
+    final String body = String.valueOf(KnoxIDFUtils.error("invalid_grant", "bad code").getEntity());
+    assertTrue(body.contains("invalid_grant"));
+    assertTrue(body.contains("bad code"));
+  }
+}
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
new file mode 100644
index 000000000..db91f336c
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.ws.rs.core.Response;
+
+import org.junit.Test;
+
+/**
+ * Verifies the dynamic-registration redirect-URI policy: HTTPS is required (RFC 8252), plain HTTP is
+ * tolerated only for loopback dev, and wildcard hosts are rejected.
+ */
+public class RegistrationRedirectUriPolicyTest {
+
+  private static Response verify(String... uris) {
+    return RegistrationResource.verifyRedirectUris(java.util.Arrays.asList(uris));
+  }
+
+  @Test
+  public void testHttpsAccepted() {
+    assertNull("A plain https redirect must be accepted.", verify("https://app.example.com/cb"));
+  }
+
+  @Test
+  public void testPlainHttpRejectedForNonLoopback() {
+    final Response response = verify("http://app.example.com/cb");
+    assertEquals(400, response.getStatus());
+    assertTrue(String.valueOf(response.getEntity()).contains("HTTPS"));
+  }
+
+  @Test
+  public void testPlainHttpAllowedForLoopback() {
+    assertNull(verify("http://localhost:8080/cb"));
+    assertNull(verify("http://127.0.0.1/cb"));
+    assertNull(verify("http://[::1]:9000/cb"));
+  }
+
+  @Test
+  public void testWildcardHostRejected() {
+    final Response response = verify("https://*.example.com/cb");
+    assertEquals(400, response.getStatus());
+  }
+
+  @Test
+  public void testEmptyListRejected() {
+    assertEquals(400, verify().getStatus());
+    assertEquals(400, RegistrationResource.verifyRedirectUris(Collections.<String>emptyList()).getStatus());
+    assertEquals(400, RegistrationResource.verifyRedirectUris((List<String>) null).getStatus());
+  }
+
+  @Test
+  public void testOneBadUriAmongGoodOnesRejectsWhole() {
+    assertEquals(400, verify("https://good.example.com/cb", "http://evil.example.com/cb").getStatus());
+  }
+}
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java
index ac0fb6cab..92e6632ed 100644
--- a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java
@@ -144,7 +144,7 @@ public class TokenResourceAuthCodeReplayTest {
     final Response response = resource.handleAuthorizationCodeFlow();
 
     assertEquals("A code already consumed by a concurrent redemption must be rejected.",
-        Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+        Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
     assertTrue("The error body should identify the invalid_grant condition.",
         String.valueOf(response.getEntity()).contains("invalid_grant"));
     assertEquals("A losing redemption must not mint any token.", 0, issuedCount.get());
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java
index dbeba8141..94ab55122 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java
@@ -26,6 +26,10 @@ public abstract class KnoxIDFArtifactStore<T> {
     private final Cache<String, T> cache;
 
     protected KnoxIDFArtifactStore(long ttl) {
+        // Entries live for twice the caller's TTL as a deliberate grace window: an artifact (e.g. an
+        // in-flight authorize/consent request) is created against the token TTL but must survive the
+        // extra round-trip through the browser/consent screen before it is consumed. Callers that
+        // finish with an entry earlier should remove() it rather than wait for expiry.
         this.cache = Caffeine.newBuilder().expireAfterWrite(ttl * 2, TimeUnit.MILLISECONDS).build();
     }
 
@@ -36,4 +40,9 @@ public abstract class KnoxIDFArtifactStore<T> {
     public T get(String key) {
         return cache.getIfPresent(key);
     }
+
+    /** Invalidates an entry so a single-use artifact cannot be replayed within its TTL grace window. */
+    public void remove(String key) {
+        cache.invalidate(key);
+    }
 }
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 57e8b3caf..8cad77b0b 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
@@ -16,22 +16,25 @@
  */
 package org.apache.knox.gateway.util.knoxidf;
 
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableSet;
 
 import java.util.Set;
 
 public interface KnoxIDFConstants {
-    String BASE_RESORCE_PATH = "knoxidf/api/v1";
+    String BASE_RESOURCE_PATH = "knoxidf/api/v1";
     String AUTH_CODE = "authorization_code";
     String CLIENT_ID = "client_id";
     String REDIRECT_URI = "redirect_uri";
     String REDIRECT_URIS = "redirect_uris";
     String RESPONSE_TYPE = "response_type";
-    Set<String> ALLOWED_RESPONSE_TYPES = Sets.newHashSet("code", "id_token", "code id_token");
+    // Immutable: an interface field is implicitly public static final, but a mutable HashSet would
+    // still let any caller add()/remove() on the shared instance. ImmutableSet forbids that.
+    Set<String> ALLOWED_RESPONSE_TYPES = ImmutableSet.of("code", "id_token", "code id_token");
     String SCOPE = "scope";
     String ALLOWED_SCOPES = "allowed_scopes";
     String OFFLINE_ACCESS_SCOPE = "offline_access";
-    Set<String> DEFAULT_SCOPES = Sets.newHashSet("openid", "profile", "email", OFFLINE_ACCESS_SCOPE);
+    // Immutable shared constant; callers that need a mutable working set copy it (new HashSet<>(...)).
+    Set<String> DEFAULT_SCOPES = ImmutableSet.of("openid", "profile", "email", OFFLINE_ACCESS_SCOPE);
     String OPENID_SCOPE = SCOPE + "=openid";
     String STATE = "state";
     String CODE = "code";
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
index 98aeb8520..423ad37ad 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
@@ -23,6 +23,8 @@ import org.apache.knox.gateway.util.JsonUtils;
 import javax.servlet.ServletContext;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.Response;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -33,11 +35,42 @@ import java.util.Set;
 
 public class KnoxIDFUtils {
 
+    /**
+     * Builds an OAuth 2.0 error response, deriving the HTTP status from the error code per
+     * RFC 6749 §5.2 (rather than the previous always-401). Most protocol errors are client errors
+     * (400); {@code invalid_client} is an authentication failure (401), {@code access_denied} is a
+     * policy denial (403), and {@code server_error} is 500. Use the three-arg overload to override
+     * the status explicitly when a call site needs a status the code alone does not imply.
+     */
     public static Response error(String error, String description) {
+        return error(error, description, statusForError(error));
+    }
+
+    public static Response error(String error, String description, Response.Status status) {
         final Map<String, String> errorMap = new HashMap<>();
         errorMap.put("error", error);
         errorMap.put("error_description", description);
-        return Response.status(Response.Status.UNAUTHORIZED).entity(JsonUtils.renderAsJsonString(errorMap)).build();
+        return Response.status(status).entity(JsonUtils.renderAsJsonString(errorMap)).build();
+    }
+
+    private static Response.Status statusForError(String error) {
+        if (error == null) {
+            return Response.Status.BAD_REQUEST;
+        }
+        switch (error) {
+            case "invalid_client":
+                return Response.Status.UNAUTHORIZED;            // 401
+            case "access_denied":
+                return Response.Status.FORBIDDEN;               // 403
+            case "server_error":
+                return Response.Status.INTERNAL_SERVER_ERROR;   // 500
+            case "temporarily_unavailable":
+                return Response.Status.SERVICE_UNAVAILABLE;     // 503
+            default:
+                // invalid_request, invalid_grant, invalid_scope, unsupported_grant_type,
+                // unsupported_response_type, unauthorized_client are all 400s.
+                return Response.Status.BAD_REQUEST;             // 400
+        }
     }
 
     public static String getRequestParamSafe(final HttpServletRequest request, final String key) {
@@ -59,7 +92,9 @@ public class KnoxIDFUtils {
         final String responseType = request.getParameter(KnoxIDFConstants.RESPONSE_TYPE);
         final String redirectUri = request.getParameter(KnoxIDFConstants.REDIRECT_URI);
         final String scope = request.getParameter(KnoxIDFConstants.SCOPE);
-        final Set<String> requestedScopes = StringUtils.isBlank(scope) ? KnoxIDFConstants.DEFAULT_SCOPES : new HashSet<>(Arrays.asList(scope.split("\\s+")));
+        // Copy DEFAULT_SCOPES into a mutable set: the constant is now an ImmutableSet, and callers
+        // downstream may add/remove scopes on the returned set.
+        final Set<String> requestedScopes = StringUtils.isBlank(scope) ? new HashSet<>(KnoxIDFConstants.DEFAULT_SCOPES) : new HashSet<>(Arrays.asList(scope.split("\\s+")));
         final String state = request.getParameter(KnoxIDFConstants.STATE);
         final String nonce = request.getParameter(KnoxIDFConstants.NONCE);
         final String codeChallenge = request.getParameter(KnoxIDFConstants.CODE_CHALLENGE);
@@ -68,12 +103,20 @@ public class KnoxIDFUtils {
     }
 
     public static String buildFederatedOpAuthRedirect(final FederatedOpConfiguration federatedOpConfiguration, final String federatedState) {
+        // URL-encode every value placed into the query string. client_id and the callback URI
+        // (which itself contains ':' '/' '?' etc.) and the state must be percent-encoded or the
+        // OP receives a malformed/parameter-split URL. CODE_RESPONSE_TYPE and OPENID_SCOPE are
+        // fixed "key=value" literals with no reserved characters, so they are appended as-is.
         return federatedOpConfiguration.getAuthorizeEndpoint()
-                + "?" + KnoxIDFConstants.CLIENT_ID + "=" + federatedOpConfiguration.getClientId()
-                + "&" + KnoxIDFConstants.REDIRECT_URI + "=" + federatedOpConfiguration.getAuthorizeCallback()
+                + "?" + KnoxIDFConstants.CLIENT_ID + "=" + urlEncode(federatedOpConfiguration.getClientId())
+                + "&" + KnoxIDFConstants.REDIRECT_URI + "=" + urlEncode(federatedOpConfiguration.getAuthorizeCallback())
                 + "&" + KnoxIDFConstants.CODE_RESPONSE_TYPE
                 + "&" + KnoxIDFConstants.OPENID_SCOPE
-                + "&" + KnoxIDFConstants.STATE + "=" + federatedState;
+                + "&" + KnoxIDFConstants.STATE + "=" + urlEncode(federatedState);
+    }
+
+    private static String urlEncode(final String value) {
+        return value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
     }
 
 }
diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java
new file mode 100644
index 000000000..04dbb9e84
--- /dev/null
+++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+
+/**
+ * Verifies the {@link KnoxIDFArtifactStore#remove(String)} single-use invalidation added so an
+ * artifact (e.g. an authorize/consent state) cannot be replayed within its TTL grace window.
+ */
+public class KnoxIDFArtifactStoreTest {
+
+  /** Minimal concrete store; the base class is abstract. TTL is large so nothing expires mid-test. */
+  private static final class TestStore extends KnoxIDFArtifactStore<String> {
+    TestStore() {
+      super(60_000L);
+    }
+  }
+
+  @Test
+  public void testPutThenGetReturnsValue() {
+    final TestStore store = new TestStore();
+    store.put("k", "v");
+    assertEquals("v", store.get("k"));
+  }
+
+  @Test
+  public void testRemoveInvalidatesEntry() {
+    final TestStore store = new TestStore();
+    store.put("state", "payload");
+    store.remove("state");
+    assertNull("A removed entry must not be retrievable (single-use replay guard).", store.get("state"));
+  }
+
+  @Test
+  public void testRemoveIsIdempotentAndSafeForUnknownKey() {
+    final TestStore store = new TestStore();
+    store.remove("never-put"); // must not throw
+    assertNull(store.get("never-put"));
+  }
+
+  @Test
+  public void testGetUnknownKeyReturnsNull() {
+    assertNull(new TestStore().get("missing"));
+  }
+}


(knox) 09/37: KNOX-3396: Return 4xx for type-mismatched register bodies and issuer-limit-reached instead of 500 (#1328)

Posted by sm...@apache.org.
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 de7c31270efbf57f251aa87c9661b97d47a8deba
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Fri Jul 24 20:32:00 2026 +0200

    KNOX-3396: Return 4xx for type-mismatched register bodies and issuer-limit-reached instead of 500 (#1328)
---
 gateway-service-knoxidf/pom.xml                    |  2 +-
 .../service/knoxidf/RegisterIssuerRequest.java     | 54 ++++++++++++++++++++++
 .../knoxidf/TrustedOidcIssuersResource.java        | 24 ++++++----
 .../knoxidf/TrustedOidcIssuersResourceTest.java    | 40 +++++++++++++++-
 4 files changed, 107 insertions(+), 13 deletions(-)

diff --git a/gateway-service-knoxidf/pom.xml b/gateway-service-knoxidf/pom.xml
index 48296b435..3c5fbce58 100644
--- a/gateway-service-knoxidf/pom.xml
+++ b/gateway-service-knoxidf/pom.xml
@@ -73,7 +73,7 @@
         </dependency>
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-core</artifactId>
+            <artifactId>jackson-annotations</artifactId>
         </dependency>
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegisterIssuerRequest.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegisterIssuerRequest.java
new file mode 100644
index 000000000..eeb83c74f
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegisterIssuerRequest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Request body for registering a trusted OIDC issuer via {@link TrustedOidcIssuersResource#registerIssuer(String)}.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RegisterIssuerRequest {
+
+  private String issuerUrl;
+  private boolean dynamicJwks;
+  private String clusterName;
+
+  public String getIssuerUrl() {
+    return issuerUrl;
+  }
+
+  public void setIssuerUrl(String issuerUrl) {
+    this.issuerUrl = issuerUrl;
+  }
+
+  public boolean isDynamicJwks() {
+    return dynamicJwks;
+  }
+
+  public void setDynamicJwks(boolean dynamicJwks) {
+    this.dynamicJwks = dynamicJwks;
+  }
+
+  public String getClusterName() {
+    return clusterName;
+  }
+
+  public void setClusterName(String clusterName) {
+    this.clusterName = clusterName;
+  }
+}
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
index b8e8b393e..4bced34ff 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java
@@ -16,7 +16,6 @@
  */
 package org.apache.knox.gateway.service.knoxidf;
 
-import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.knox.gateway.audit.api.Action;
 import org.apache.knox.gateway.audit.api.ActionOutcome;
@@ -90,14 +89,15 @@ public class TrustedOidcIssuersResource {
     String outcome = ActionOutcome.FAILURE;
 
     try {
-      final Map<String, Object> parsed;
+      final RegisterIssuerRequest parsed;
       try {
-        parsed = MAPPER.readValue(body, new TypeReference<Map<String, Object>>() {});
+        parsed = MAPPER.readValue(body, RegisterIssuerRequest.class);
       } catch (IOException e) {
-        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", "Malformed JSON body");
+        return errorResponse(Response.Status.BAD_REQUEST, "invalid_request",
+            "Malformed or invalid JSON body");
       }
 
-      final String rawUrl = (String) parsed.get("issuerUrl");
+      final String rawUrl = parsed.getIssuerUrl();
       issuerUrl = (rawUrl != null && !rawUrl.isEmpty()) ? rawUrl : "UNKNOWN_ISSUER";
 
       if (rawUrl == null || rawUrl.isEmpty()) {
@@ -112,13 +112,17 @@ public class TrustedOidcIssuersResource {
             "Issuer already registered: " + rawUrl);
       }
 
-      final boolean dynamicJwks = Boolean.TRUE.equals(parsed.get("dynamicJwks"));
-      final String clusterName = (String) parsed.get("clusterName");
-
-      trustedIssuers.register(new TrustedOidcIssuer(rawUrl, dynamicJwks, clusterName,
-          Instant.now(), operatorId));
+      trustedIssuers.register(new TrustedOidcIssuer(rawUrl, parsed.isDynamicJwks(),
+          parsed.getClusterName(), Instant.now(), operatorId));
       outcome = ActionOutcome.SUCCESS;
       return Response.status(Response.Status.CREATED).build();
+    } catch (IllegalStateException e) {
+      // The service throws IllegalStateException when the configured maximum number of
+      // registered issuers (MAX_TRUSTED_ISSUERS) is reached. This is an operator-facing
+      // capacity condition, distinct from an internal storage failure, so report it as a
+      // 409 rather than lumping it into the generic 500 storage_error path below.
+      return errorResponse(Response.Status.CONFLICT, "issuer_limit_reached",
+          "Maximum number of registered trusted issuers reached");
     } catch (RuntimeException e) {
       return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error",
           "Failed to register issuer");
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java
index e082d3255..b5cea534e 100644
--- a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java
@@ -16,7 +16,6 @@
  */
 package org.apache.knox.gateway.service.knoxidf;
 
-import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.knox.gateway.audit.api.Action;
 import org.apache.knox.gateway.audit.api.ActionOutcome;
@@ -251,6 +250,42 @@ public class TrustedOidcIssuersResourceTest {
     EasyMock.verify(mockService, mockAuditor);
   }
 
+  @Test
+  public void testRegisterWrongTypeFieldReturnsBadRequest() {
+    // A syntactically valid JSON body with a type-mismatched field (clusterName as an
+    // array instead of a string). Binding to the typed RegisterIssuerRequest bean makes
+    // Jackson reject this during deserialization, so it is a 400 invalid_request rather
+    // than a ClassCastException surfacing as a 500. No service calls are expected; audit
+    // fires with the INVALID_REQUEST sentinel because parsing failed before the URL was read.
+    expectAudit("INVALID_REQUEST", ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.registerIssuer(
+        "{\"issuerUrl\":\"" + ISSUER_A + "\",\"clusterName\":[1,2,3]}");
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertErrorField(response, "invalid_request");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
+  @Test
+  public void testRegisterIssuerLimitReached() {
+    // The service throws IllegalStateException when MAX_TRUSTED_ISSUERS is reached. This is
+    // an operator-facing capacity condition and must map to 409 issuer_limit_reached, not
+    // the generic 500 storage_error used for genuine storage failures.
+    EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once();
+    mockService.register(EasyMock.anyObject(TrustedOidcIssuer.class));
+    EasyMock.expectLastCall().andThrow(new IllegalStateException("MAX_TRUSTED_ISSUERS (100) reached")).once();
+    expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_registered");
+    EasyMock.replay(mockService, mockAuditor);
+
+    final Response response = resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null));
+
+    assertEquals(Response.Status.CONFLICT.getStatusCode(), response.getStatus());
+    assertErrorField(response, "issuer_limit_reached");
+    EasyMock.verify(mockService, mockAuditor);
+  }
+
   // ---------------------------------------------------------------------------
   // DELETE /
   // ---------------------------------------------------------------------------
@@ -531,8 +566,9 @@ public class TrustedOidcIssuersResourceTest {
         body.contains(expectedError));
   }
 
+  @SuppressWarnings("unchecked")
   private static List<Map<String, Object>> parseJsonList(String json) throws Exception {
-    return new ObjectMapper().readValue(json, new TypeReference<List<Map<String, Object>>>() {});
+    return new ObjectMapper().readValue(json, List.class);
   }
 
   private static Map<String, Object> findByIssuerUrl(List<Map<String, Object>> list,


(knox) 35/37: KNOX-3414: run KnoxIDF federation E2E tests optionally in PRs

Posted by sm...@apache.org.
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 8d6840b29069b3232a2a099732637a884e80bd44
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 11:51:36 2026 +0200

    KNOX-3414: run KnoxIDF federation E2E tests optionally in PRs
---
 .github/PULL_REQUEST_TEMPLATE.md | 10 +++++++
 .github/workflows/tests.yml      | 56 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 66 insertions(+)

diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index ffec53663..d41f88fd3 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -13,6 +13,16 @@
 ## Integration Tests
 (Please add or update integration tests [`.github/workflows/tests`](.github/workflows/tests) for the feature you are adding. If no unit test is added, please explain why. Check out [`.github/workflows/tests/README.md`](./workflows/tests/README.md) for instructions)
 
+### Opt-in test suites (PR labels)
+Some integration suites are expensive and are **not** run on every PR. Add the corresponding label to this PR to run them:
+
+| Label | Runs |
+|-------|------|
+| `test-federation` | KnoxIDF federation E2E (`test_knoxidf_federation.py`). Stands up a real Keycloak as an external OpenID Provider and drives the full broker flow. Adds a few minutes (image pull + realm import). |
+| `skip-tests` | Skips the entire Docker Compose test job. |
+
+**When to add the label:** these labels only take effect on runs triggered by opening the PR, pushing a commit, or reopening the PR — the workflow does not run on a label change. Add the label **before opening the PR** (or before your next push). Adding it after the checks have already finished will **not** start a new run; push a commit or close/reopen the PR to trigger one with the label applied.
+
 ## UI changes
 (If this patch involves UI changes, please attach a screen-shot; otherwise, remove this)
 
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 9fbd80c41..5c39d25d2 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -69,6 +69,61 @@ jobs:
           # Run the tests service defined in docker-compose.yml
           docker compose -f ./.github/workflows/compose/docker-compose.yml up --exit-code-from tests tests
 
+      # KnoxIDF federation E2E runs only when the PR carries the 'test-federation'
+      # label. It stands up a real Keycloak (external OpenID Provider) via the
+      # override compose file and pulls a multi-hundred-MB image plus minutes of
+      # realm import, so it is opt-in rather than on every PR. It runs here, before
+      # the single-EKU steps, so 'knox' is still in its base (non-mTLS) config; the
+      # override only adds a keycloak dependency to knox, it does not reconfigure it.
+      - name: Start Keycloak + Knox for federation
+        if: contains(github.event.pull_request.labels.*.name, 'test-federation')
+        run: |
+          # 'up -d knox' blocks until keycloak is service_healthy (override depends_on).
+          docker compose \
+            -f ./.github/workflows/compose/docker-compose.yml \
+            -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \
+            up -d knox keycloak
+
+      - name: Wait for federation stack to stabilize
+        if: contains(github.event.pull_request.labels.*.name, 'test-federation')
+        run: sleep 30  # Adjust as needed for services startup time
+
+      - name: Run KnoxIDF Federation Tests
+        id: knoxidf_federation_tests
+        if: contains(github.event.pull_request.labels.*.name, 'test-federation')
+        run: |
+          # Emit a distinct JUnit file so it reports as its own test suite.
+          docker compose \
+            -f ./.github/workflows/compose/docker-compose.yml \
+            -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \
+            run --rm tests bash -c "pip install -r requirements.txt \
+            && pytest test_knoxidf_federation.py --junitxml=test-results-federation.xml"
+
+      # Evidence gathering mirrors the single-EKU dumps: on a federation failure,
+      # capture container status and the knox + keycloak logs so a broker-flow
+      # failure (bad callback/issuer/JWKS) can be told apart from a Keycloak that
+      # never came up.
+      - name: Dump federation diagnostics on failure
+        if: failure() && steps.knoxidf_federation_tests.outcome == 'failure'
+        run: |
+          echo '===== docker compose ps -a ====='
+          docker compose \
+            -f ./.github/workflows/compose/docker-compose.yml \
+            -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \
+            ps -a || true
+          echo '===== knox container logs ====='
+          docker compose \
+            -f ./.github/workflows/compose/docker-compose.yml \
+            -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \
+            logs --no-color knox || true
+          echo '===== keycloak container logs ====='
+          docker compose \
+            -f ./.github/workflows/compose/docker-compose.yml \
+            -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \
+            logs --no-color keycloak || true
+          echo '===== gateway.log ====='
+          cat ./.github/workflows/compose/logs/gateway.log || true
+
       # Single-EKU mTLS runs as its own pass. Its override turns on
       # gateway.client.auth.needed=true, which would break the default
       # (no-client-cert) tests above, so the gateway is recreated with the
@@ -170,6 +225,7 @@ jobs:
             .github/workflows/tests/test-results.xml
             .github/workflows/tests/test-results-single-eku.xml
             .github/workflows/tests/test-results-single-eku-no-mtls.xml
+            .github/workflows/tests/test-results-federation.xml
 
       - name: Archive Knox Logs
         if: always()


(knox) 01/37: Knox as OIDC Provider (#1215)

Posted by sm...@apache.org.
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 5da3d9be65cba602dd6ab60e0bbf9019a56ba195
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed May 20 22:31:52 2026 +0200

    Knox as OIDC Provider (#1215)
    
    * KnoxIDF - Initial commit
    * KnoxIDF - multi OP support
    * KnoxIDF - make token endpoint configurable during discovery
    * KnoxIDF - Code cleanup and bug fixes
    * KnoxIDF - Multi OP enablement improvements and code adoption to Larry's recent changes
    * KnoxIDF - Add REFRESH_TOKEN support
    * KnoxIDF - Automatically enable JdbcFederatedIdentityService when KnoxIDF is present in any topology
    * KnoxIDF - Added Docker-based integration tests
    * KnoxIDF: configurable user params provider (only LDAP for now)
    * KnoxIDF: add support for auth code flow with PKCE
    * KnoxIDF: fix an issue with the empty user params provider implementation
    * KnoxIDF: Refactor Docker build to use local Maven artifacts and unify CI/Dev workflows
---
 .github/workflows/build/Dockerfile                 |   5 +-
 .../build/conf/topologies/knoxidf-ldap.xml         |  67 +++
 .../build/conf/topologies/knoxidf-token.xml        |  42 ++
 .github/workflows/publish-test-results.yml         |   2 +-
 .github/workflows/tests.yml                        |  30 ++
 .github/workflows/tests/common_utils.py            |  31 ++
 .github/workflows/tests/test_knoxidf.py            | 344 ++++++++++++++++
 .../main/resources/build-tools/spotbugs-filter.xml |   5 +
 .../applications/knoxauth/app/js/knoxauth.js       |  37 +-
 .../resources/applications/knoxauth/app/login.html |   8 +-
 .../applications/knoxauth/app/styles/knox.css      |  67 +++
 .../src/main/resources/docker/Dockerfile           |   1 -
 .../federation/jwt/filter/JWTFederationFilter.java |  20 +-
 .../jwt/filter/SSOCookieFederationFilter.java      |  25 ++
 .../provider/federation/SSOCookieProviderTest.java |  36 +-
 .../knox/gateway/filter/RedirectToUrlFilter.java   |   2 +-
 gateway-release/home/conf/topologies/knoxsso.xml   |   4 +
 gateway-release/home/conf/users.ldif               |  20 +-
 gateway-release/pom.xml                            |   4 +
 .../apache/knox/gateway/UrlEncodedFormRequest.java |  14 +-
 .../database/AbstractDataSourceFactory.java        |   8 +
 .../apache/knox/gateway/database/DatabaseType.java |  39 +-
 .../apache/knox/gateway/database/KnoxDatabase.java |  23 +-
 .../knox/gateway/deploy/DeploymentFactory.java     |   7 +
 .../gateway/services/DefaultGatewayServices.java   |   2 +
 .../factory/FederatedIdentityServiceFactory.java   |  97 +++++
 .../EmptyFederatedIdentitityService.java           |  51 +++
 .../federation/FederatedIdentityDatabase.java      | 132 ++++++
 .../FederatedIdentityServiceMessages.java          |  35 ++
 .../federation/JdbcFederatedIdentityService.java   | 108 +++++
 .../services/token/impl/TokenStateDatabase.java    |  14 +-
 ...org.apache.knox.gateway.services.ServiceFactory |   9 +-
 ...eateKnoxIDFFederatedIdentityAttributesTable.sql |  21 +
 ...noxIDFFederatedIdentityAttributesTableDerby.sql |  22 +
 ...oxIDFFederatedIdentityAttributesTableOracle.sql |  22 +
 .../createKnoxIDFFederatedIdentityTable.sql        |  25 ++
 .../createKnoxIDFFederatedIdentityTableDerby.sql   |  25 ++
 .../createKnoxIDFFederatedIdentityTableOracle.sql  |  25 ++
 .../services/AbstractGatewayServicesTest.java      |   3 +-
 .../pom.xml                                        |  95 ++---
 .../service/knoxidf/AuthConsentServlet.java        | 132 ++++++
 .../gateway/service/knoxidf/AuthorizeResource.java | 408 +++++++++++++++++++
 .../gateway/service/knoxidf/DiscoveryResource.java |  76 ++++
 .../knox/gateway/service/knoxidf/JwksResource.java |  26 +-
 .../knox/gateway/service/knoxidf/OIDCScope.java    |  66 +++
 .../service/knoxidf/RegistrationResource.java      | 153 +++++++
 .../gateway/service/knoxidf/TokenResource.java     | 452 +++++++++++++++++++++
 .../gateway/service/knoxidf/UserInfoResource.java  | 141 +++++++
 .../KnoxIDFServiceDeploymentContributor.java       |  29 +-
 .../userparams/EmptyUserParamsProvider.java        |  15 +-
 .../knoxidf/userparams/LdapUserParamsProvider.java | 201 +++++++++
 .../knoxidf/userparams/UserParamsProvider.java     |  17 +-
 .../userparams/UserParamsProviderFactory.java      |  15 +-
 ...nox.gateway.deploy.ServiceDeploymentContributor |  18 +
 .../gateway/service/knoxsso/WebSSOResource.java    |  84 ++--
 .../knoxtoken/ClientCredentialsResource.java       |   6 +
 .../gateway/service/knoxtoken/TokenResource.java   |  83 ++--
 .../gateway/security/CommonTokenConstants.java     |   2 +
 .../apache/knox/gateway/services/ServiceType.java  |   3 +-
 .../knoxidf/federation/FederatedIdentity.java      |  87 ++++
 .../federation/FederatedIdentityService.java}      |  21 +-
 .../FederatedIdentityServiceException.java}        |  15 +-
 .../services/security/token/JWTokenAttributes.java | 130 +++---
 .../security/token/JWTokenAttributesBuilder.java   |  16 +-
 .../services/security/token/TokenMetadata.java     |  10 +-
 .../services/security/token/TokenMetadataType.java |   2 +-
 .../services/security/token/TokenUtils.java        |  17 +
 .../gateway/services/security/token/impl/JWT.java  |   3 +
 .../services/security/token/impl/JWTToken.java     |  17 +
 gateway-util-common/pom.xml                        |  17 +
 .../org/apache/knox/gateway/util/JsonUtils.java    |   9 +-
 .../util/knoxidf/AuthorizeRequestMetadata.java     | 124 ++++++
 .../knoxidf/AuthorizeRequestMetadataStore.java     |  20 +-
 .../util/knoxidf/FederatedOpConfiguration.java     |  81 ++++
 .../knoxidf/FederatedOpConfigurationFactory.java   |  42 ++
 .../knoxidf/FederatedOpConfigurationStore.java     |  22 +-
 .../gateway/util/knoxidf/KnoxIDFArtifactStore.java |  26 +-
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |  60 +++
 .../knox/gateway/util/knoxidf/KnoxIDFUtils.java    | 107 +++++
 pom.xml                                            |  17 +
 80 files changed, 3994 insertions(+), 303 deletions(-)

diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile
index 1781ead94..5c0407661 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 000000000..a82920bdb
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<topology>
+    <gateway>
+      <provider>
+         <role>authentication</role>
+         <name>ShiroProvider</name>
+         <enabled>true</enabled>
+         <param>
+            <name>main.ldapRealm</name>
+            <value>org.apache.knox.gateway.shirorealm.KnoxLdapRealm</value>
+         </param>
+         <param>
+            <name>main.ldapRealm.userDnTemplate</name>
+            <value>uid={0},ou=people,dc=hadoop,dc=apache,dc=org</value>
+         </param>
+         <param>
+            <name>main.ldapRealm.contextFactory.url</name>
+            <value>ldap://ldap:33389</value>
+         </param>
+         <param>
+            <name>main.ldapRealm.contextFactory.authenticationMechanism</name>
+            <value>simple</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/.well-known/openid-configuration</name>
+            <value>anon</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/client/register</name>
+            <value>anon</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/authorize/callback</name>
+            <value>anon</value>
+         </param>
+         <param>
+            <name>urls./knoxidf/api/v1/jwks</name>
+            <value>anon</value>
+          </param>
+         <param>
+            <name>urls./**</name>
+            <value>authcBasic</value>
+         </param>
+      </provider>
+      <provider>
+            <role>identity-assertion</role>
+            <name>Default</name>
+            <enabled>true</enabled>
+      </provider>
+    </gateway>
+
+    <service>
+        <role>KNOXIDF</role>
+        <param>
+            <name>knoxidf.knox.token.ttl</name>
+            <value>60000</value>
+        </param>
+        <param>
+            <name>knoxidf.knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+        <param>
+            <name>token.exchange.topology.name</name>
+            <value>knoxidf-token</value>
+        </param>
+    </service>
+</topology>
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 000000000..fdc11d62b
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxidf-token.xml
@@ -0,0 +1,42 @@
+<?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>
+      </provider>
+      <provider>
+            <role>identity-assertion</role>
+            <name>Default</name>
+            <enabled>true</enabled>
+      </provider>
+    </gateway>
+
+    <service>
+        <role>KNOXIDF</role>
+        <param>
+           <name>knoxidf.knox.token.ttl</name>
+           <value>86400000</value>
+        </param>
+        <param>
+            <name>knoxidf.knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+    </service>
+    <service>
+        <role>KNOXTOKEN</role>
+        <param>
+            <name>knox.token.ttl</name>
+            <value>60000</value>
+        </param>
+        <param>
+            <name>knox.token.limit.per.user</name>
+            <value>-1</value>
+        </param>
+    </service>
+</topology>
diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml
index 0f584f599..621830115 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 da1a7c78f..9fbd80c41 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 0a773b44e..e80193374 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 Exception as e:
+        print(f"Failed to decode token for claim '{claim}': {e}")
+        return None
\ No newline at end of file
diff --git a/.github/workflows/tests/test_knoxidf.py b/.github/workflows/tests/test_knoxidf.py
new file mode 100644
index 000000000..918cc37d2
--- /dev/null
+++ b/.github/workflows/tests/test_knoxidf.py
@@ -0,0 +1,344 @@
+# 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.
+
+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):
+    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
+        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}")
+        client_id = reg_info["client_id"]
+        client_secret = reg_info["client_secret"]
+
+        # 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
+        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}")
+        client_id = reg_info["client_id"]
+        client_secret = reg_info["client_secret"]
+
+        # 2. Authorize (with Basic Auth for the user 'guest')
+        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize"
+        params = {
+            "response_type": "code",
+            "client_id": client_id,
+            "redirect_uri": "http://localhost/callback",
+            "scope": "openid offline_access",
+            "state": "test_state",
+            "auto_consent": "true"
+        }
+        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)
+        
+        # Should be a redirect to the callback URL
+        self.assertEqual(response.status_code, 303)
+        location = response.headers.get("Location")
+        self.assertIsNotNone(location)
+        self.assertTrue(location.startswith("http://localhost/callback"))
+        
+        parsed_url = urlparse(location)
+        query_params = parse_qs(parsed_url.query)
+        self.assertIn("code", query_params)
+        self.assertIn("state", query_params)
+        self.assertEqual(query_params["state"][0], "test_state")
+        code = query_params["code"][0]
+
+        # 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(f"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 = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().replace('=', '')
+
+        # 3. Authorize
+        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/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"
+        }
+        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
+        self.assertEqual(response.status_code, 303)
+        location = response.headers.get("Location")
+        code = parse_qs(urlparse(location).query)["code"][0]
+
+        # 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
+        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/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"
+        }
+        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
+        self.assertEqual(response.status_code, 303)
+        location = response.headers.get("Location")
+        code = parse_qs(urlparse(location).query)["code"][0]
+
+        # 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 = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().replace('=', '')
+
+        # Authorize
+        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/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"
+        }
+        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
+        code = parse_qs(urlparse(response.headers.get("Location")).query)["code"][0]
+
+        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
+        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
+        code = parse_qs(urlparse(response.headers.get("Location")).query)["code"][0]
+
+        # 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"
+        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))
+        reg_info = response.json()
+        return reg_info["client_id"], reg_info["client_secret"]
+
+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 fb4d7857d..d27630435 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.
     <Class name="org.apache.knox.gateway.audit.log4j.correlation.Log4jCorrelationService" />
     <Bug pattern="OBJECT_DESERIALIZATION" />
   </Match>
+
+  <Match>
+    <Class name="org.apache.knox.gateway.service.knoxidf.AuthConsentServlet" />
+    <Bug pattern="UNVALIDATED_REDIRECT" />
+  </Match>
 </FindBugsFilter>
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 4f91c2fca..5a304a556 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(`
+            <div class="or-separator">
+                <span>Or</span>
+            </div>
+        `);
+	}
+
+	ops.forEach(op => {
+		container.append(`
+            <div class="fed-op-btn" onclick="loginWithOp('${op}')">
+                <span class="fed-op-icon">🌐</span>
+                <span class="fed-op-label">Continue with ${op}</span>
+            </div>
+        `);
+	});
+};
+
+var loginWithOp = function(opName) {
+	const sessionId = getSimpleParam("federatedOpLoginSession");
+	var pathname = window.location.pathname;
+	var topologyContext = pathname.replace(loginPageSuffix, "");
+	redirect(topologyContext + webssoURLBase + "/federated/op?fedOpSid=" + sessionId + "&fedOpName=" + encodeURIComponent(opName));
+};
+
 var login = function() {
 	var pathname = window.location.pathname;
 	var topologyContext = pathname.replace(loginPageSuffix, "");
diff --git a/gateway-applications/src/main/resources/applications/knoxauth/app/login.html b/gateway-applications/src/main/resources/applications/knoxauth/app/login.html
index 6a30f6106..4606a81e0 100644
--- a/gateway-applications/src/main/resources/applications/knoxauth/app/login.html
+++ b/gateway-applications/src/main/resources/applications/knoxauth/app/login.html
@@ -28,7 +28,7 @@
         <link href="styles/knox.css" media="all" rel="stylesheet" type="text/css" >
 
         <script src="libs/bower/jquery/js/jquery-3.5.1.min.js" ></script>
-    <script type="text/javascript" src="js/knoxauth.js"></script>
+        <script type="text/javascript" src="js/knoxauth.js"></script>
 
         <!-- Knox Theme Configuration -->
         <script type="text/javascript" src="theme-config.js"></script>
@@ -133,7 +133,7 @@
         </script>
 
     </head>
-    <body class="login" style="">
+    <body class="login" style="" onload="loadFederatedOpLinks();">
         <!-- Page content
         ================================================== -->
         <section id="signin-container" style="margin-top: 4.5px;">
@@ -160,5 +160,9 @@
             </form>
         </section>
 
+        <div id="federated-op-container" style="margin-top:20px; text-align:center;">
+            <!-- Federated login buttons will be injected by JS -->
+        </div>
+
     </body>
 </html>
diff --git a/gateway-applications/src/main/resources/applications/knoxauth/app/styles/knox.css b/gateway-applications/src/main/resources/applications/knoxauth/app/styles/knox.css
index ba3873571..e2e3d2f81 100644
--- a/gateway-applications/src/main/resources/applications/knoxauth/app/styles/knox.css
+++ b/gateway-applications/src/main/resources/applications/knoxauth/app/styles/knox.css
@@ -1984,4 +1984,71 @@ input[type="radio"], input[type="checkbox"] {margin-top: 0;}
     margin-left: -5px;
     margin-top: -2px;
     font-size: 11px;
+}
+
+.or-separator {
+  display: flex;
+  align-items: center;
+  text-align: center;
+  margin: 20px auto;
+  width: 250px; /* or whatever fits your design */
+  color: #888;
+  font-family: sans-serif;
+  font-size: 14px;
+}
+
+.or-separator::before,
+.or-separator::after {
+  content: "";
+  flex: 1;
+  height: 1px;
+  background: #ccc;
+}
+
+.or-separator::before {
+  margin-right: 8px;
+}
+
+.or-separator::after {
+  margin-left: 8px;
+}
+
+#federated-op-container {
+  margin: 20px auto 0; /* auto left/right centers it */
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: fit-content; /* shrink to fit content */
+}
+
+.fed-op-btn {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 16px;
+  border-radius: 6px;
+  background: #f7f7f7;
+  border: 1px solid #d0d0d0;
+  cursor: pointer;
+  font-family: sans-serif;
+  font-size: 15px;
+  transition: background 0.2s, transform 0.1s;
+  user-select: none;
+}
+
+.fed-op-btn:hover {
+  background: #ececec;
+}
+
+.fed-op-btn:active {
+  transform: scale(0.97);
+}
+
+.fed-op-icon {
+  font-size: 18px;
+}
+
+.fed-op-label {
+  flex: 1;
+  text-align: left;
 }
\ No newline at end of file
diff --git a/gateway-docker/src/main/resources/docker/Dockerfile b/gateway-docker/src/main/resources/docker/Dockerfile
index 2e8fc7234..001a9aa15 100644
--- a/gateway-docker/src/main/resources/docker/Dockerfile
+++ b/gateway-docker/src/main/resources/docker/Dockerfile
@@ -12,7 +12,6 @@
 # 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.
-
 FROM dhi.io/eclipse-temurin:17-jdk-debian13-dev AS build
 LABEL maintainer="Apache Knox <de...@knox.apache.org>"
 
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 4e1dc8749..30ab50603 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
@@ -29,6 +29,7 @@ import org.apache.knox.gateway.util.AuthFilterUtils;
 import org.apache.knox.gateway.util.CertificateUtils;
 import org.apache.knox.gateway.util.CookieUtils;
 import org.apache.knox.gateway.util.ServletRequestUtils;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
 
 import javax.security.auth.Subject;
 import javax.servlet.FilterChain;
@@ -48,10 +49,11 @@ import java.util.Locale;
 import java.util.Set;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+import static org.apache.knox.gateway.security.CommonTokenConstants.AUTH_CODE;
 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.GRANT_TYPE;
 import static org.apache.knox.gateway.util.AuthFilterUtils.DEFAULT_AUTH_UNAUTHENTICATED_PATHS_PARAM;
 
 public class JWTFederationFilter extends AbstractJWTFilter {
@@ -199,6 +201,7 @@ public class JWTFederationFilter extends AbstractJWTFilter {
           JWT token = parseAndValidateJWT((HttpServletRequest) request, (HttpServletResponse) response, chain, tokenValue);
           if (token != null) {
             Subject subject = createSubjectFromToken(token);
+            addKnoxIDFAttributes(request, token);
             continueWithEstablishedSecurityContext(subject, (HttpServletRequest) request, (HttpServletResponse) response, chain);
           }
         } catch (ParseException | UnknownTokenException ex) {
@@ -220,7 +223,8 @@ public class JWTFederationFilter extends AbstractJWTFilter {
         }
         if (validateToken((HttpServletRequest) request, (HttpServletResponse) response, chain, tokenId, passcode)) {
           try {
-            Subject subject = createSubjectFromTokenIdentifier(tokenId);
+            final Subject subject = createSubjectFromTokenIdentifier(tokenId);
+            request.setAttribute(KnoxIDFConstants.TOKEN_ID_ATTRIBUTE, tokenId);
             continueWithEstablishedSecurityContext(subject, (HttpServletRequest) request, (HttpServletResponse) response, chain);
           } catch (UnknownTokenException e) {
             ((HttpServletResponse) response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
@@ -234,6 +238,14 @@ public class JWTFederationFilter extends AbstractJWTFilter {
     }
   }
 
+  private static void addKnoxIDFAttributes(ServletRequest request, JWT token) {
+    request.setAttribute(KnoxIDFConstants.TOKEN_ID_ATTRIBUTE, TokenUtils.getTokenId(token));
+    final String scope = token.getClaim(KnoxIDFConstants.SCOPE);
+    if (scope != null) {
+      request.setAttribute(KnoxIDFConstants.SCOPE_ATTRIBUTE, token.getClaim(scope));
+    }
+  }
+
   private void validateClientID(HttpServletRequest request, String tokenValue) {
     final String clientID = request.getParameter(CLIENT_ID);
     validateClientID(clientID, tokenValue);
@@ -332,8 +344,8 @@ 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)) {
-          if (clientAssertionType != null && CLIENT_ASSERTION_JWT_BEARER.equals(clientAssertionType)) {
+        if (CLIENT_CREDENTIALS.equals(grantType) || AUTH_CODE.equals(grantType)) {
+          if (CLIENT_ASSERTION_JWT_BEARER.equals(clientAssertionType)) {
             // short lived client assertion token expected
             return getClientTokenFromParams(unwrappedRequest, CLIENT_ASSERTION);
           }
diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/SSOCookieFederationFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/SSOCookieFederationFilter.java
index a8e7b8f8d..7558f5a2e 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/SSOCookieFederationFilter.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/SSOCookieFederationFilter.java
@@ -31,6 +31,10 @@ import org.apache.knox.gateway.util.AuthFilterUtils;
 import org.apache.knox.gateway.util.CertificateUtils;
 import org.apache.knox.gateway.util.CookieUtils;
 import org.apache.knox.gateway.util.Urls;
+import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadataStore;
+import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration;
+import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils;
 import org.eclipse.jetty.http.MimeTypes;
 
 import javax.security.auth.Subject;
@@ -45,12 +49,15 @@ import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 public class SSOCookieFederationFilter extends AbstractJWTFilter {
   private static final JWTMessages LOGGER = MessagesFactory.get( JWTMessages.class );
@@ -103,6 +110,8 @@ public class SSOCookieFederationFilter extends AbstractJWTFilter {
   private boolean shouldUseOriginalUrlFromHeader = DEFAULT_SHOULD_USE_ORIGINAL_URL_FROM_HEADER;
   private boolean verifyOriginalUrlFromHeaderDomain = DEFAULT_VERIFY_ORIGINAL_URL_FROM_HEADER_DOMAIN;
   private final List<String> verifyOriginalUrlFromHeaderDomainWhitelist = new ArrayList<>();
+  private final AuthorizeRequestMetadataStore authorizeRequestMetadataStore = AuthorizeRequestMetadataStore.getInstance(120000L);
+  private final FederatedOpConfigurationStore federatedOpConfigurationStore = FederatedOpConfigurationStore.getInstance(120000L);
   private String originalUrlHeaderName;
 
   @Override
@@ -337,6 +346,22 @@ public class SSOCookieFederationFilter extends AbstractJWTFilter {
       delimiter = "&";
     }
 
+    final Set<FederatedOpConfiguration> enabledFederatedOpConfigs = KnoxIDFUtils.fetchEnabledFederatedOpConfigs(request);
+    if (!enabledFederatedOpConfigs.isEmpty()) {
+      final String loginSessionId = request.getSession().getId();
+      authorizeRequestMetadataStore.put(loginSessionId, KnoxIDFUtils.buildAuthRequestMetadata(request));
+      federatedOpConfigurationStore.put(loginSessionId, enabledFederatedOpConfigs);
+      final List<String> opNames = enabledFederatedOpConfigs.stream()
+              .sorted(Comparator.comparing(FederatedOpConfiguration::getName))
+              .map(FederatedOpConfiguration::getName)
+              .collect(Collectors.toList());
+      providerURL += delimiter
+              + "federatedOpLoginSession=" + URLEncoder.encode(loginSessionId, StandardCharsets.UTF_8)
+              + "&federatedOpNames=" + URLEncoder.encode(String.join(",", opNames), StandardCharsets.UTF_8);
+
+      delimiter = "&";
+    }
+
     if(shouldUseOriginalUrlFromHeader && (request.getHeader(originalUrlHeaderName) != null) && !request.getHeader(originalUrlHeaderName).trim().isEmpty()) {
       final String originalUrlFromHeader = request.getHeader(originalUrlHeaderName);
       LOGGER.usingOriginalUrlFromHeader(originalUrlFromHeader);
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java
index 90c056f2e..d96b2442a 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java
@@ -17,23 +17,7 @@
  */
 package org.apache.knox.gateway.provider.federation;
 
-import static org.apache.knox.gateway.provider.federation.jwt.filter.SSOCookieFederationFilter.XHR_HEADER;
-import static org.apache.knox.gateway.provider.federation.jwt.filter.SSOCookieFederationFilter.XHR_VALUE;
-import static org.junit.Assert.fail;
-
-import java.nio.charset.StandardCharsets;
-import java.security.Principal;
-import java.time.Instant;
-import java.util.Properties;
-import java.util.Date;
-import java.util.Set;
-import java.util.concurrent.ThreadLocalRandom;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
+import com.nimbusds.jwt.SignedJWT;
 import org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter;
 import org.apache.knox.gateway.provider.federation.jwt.filter.SSOCookieFederationFilter;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
@@ -44,11 +28,25 @@ import org.easymock.EasyMock;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
-
-import com.nimbusds.jwt.SignedJWT;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.servlet.ServletException;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+
+import static org.apache.knox.gateway.provider.federation.jwt.filter.SSOCookieFederationFilter.XHR_HEADER;
+import static org.apache.knox.gateway.provider.federation.jwt.filter.SSOCookieFederationFilter.XHR_VALUE;
+import static org.junit.Assert.fail;
+
 public class SSOCookieProviderTest extends AbstractJWTFilterTest {
   private static final Logger LOGGER = LoggerFactory.getLogger(SSOCookieProviderTest.class);
 
diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java
index f0f14b365..7501dbe4b 100644
--- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java
+++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java
@@ -47,7 +47,7 @@ public class RedirectToUrlFilter extends AbstractGatewayFilter {
 
   @Override
   protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
-    if (redirectUrl != null && request.getHeader("Authorization") == null) {
+    if (redirectUrl != null && request.getHeader("Authorization") == null && request.getParameter("fedOpSid") == null) {
       response.sendRedirect(redirectUrl + getOriginalQueryString(request));
     }
     chain.doFilter(request, response);
diff --git a/gateway-release/home/conf/topologies/knoxsso.xml b/gateway-release/home/conf/topologies/knoxsso.xml
index 99600f874..cfee258c3 100644
--- a/gateway-release/home/conf/topologies/knoxsso.xml
+++ b/gateway-release/home/conf/topologies/knoxsso.xml
@@ -73,6 +73,10 @@
                 <name>main.ldapRealm.contextFactory.authenticationMechanism</name>
                 <value>simple</value>
             </param>
+            <param>
+                <name>urls./api/v1/websso/federated/op</name>
+                <value>anon</value>
+            </param>
             <param>
                 <name>urls./**</name>
                 <value>authcBasic</value>
diff --git a/gateway-release/home/conf/users.ldif b/gateway-release/home/conf/users.ldif
index 4f1c6a955..999f82403 100644
--- a/gateway-release/home/conf/users.ldif
+++ b/gateway-release/home/conf/users.ldif
@@ -39,7 +39,9 @@ objectclass:organizationalPerson
 objectclass:inetOrgPerson
 cn: Guest
 sn: User
+givenName: Guest
 uid: guest
+mail: guest@example.org
 userPassword:guest-password
 
 # entry for sample user admin
@@ -48,9 +50,11 @@ objectclass:top
 objectclass:person
 objectclass:organizationalPerson
 objectclass:inetOrgPerson
-cn: Admin
-sn: Admin
+cn: System Administrator
+sn: Administrator
+givenName: System
 uid: admin
+mail: admin@example.org
 userPassword:admin-password
 
 # entry for sample user sam
@@ -59,9 +63,11 @@ objectclass:top
 objectclass:person
 objectclass:organizationalPerson
 objectclass:inetOrgPerson
-cn: sam
-sn: sam
+cn: Sam Peterson
+sn: Peterson
+givenName: Sam
 uid: sam
+mail: sam@example.org
 userPassword:sam-password
 
 # entry for sample user tom
@@ -70,9 +76,11 @@ objectclass:top
 objectclass:person
 objectclass:organizationalPerson
 objectclass:inetOrgPerson
-cn: tom
-sn: tom
+cn: Tom Richards
+sn: Richards
+givenName: Tom
 uid: tom
+mail: tom@example.org
 userPassword:tom-password
 
 # create FIRST Level groups branch
diff --git a/gateway-release/pom.xml b/gateway-release/pom.xml
index 7df1c6c22..00566f9d7 100644
--- a/gateway-release/pom.xml
+++ b/gateway-release/pom.xml
@@ -524,5 +524,9 @@
             <groupId>org.apache.knox</groupId>
             <artifactId>gateway-service-restcatalog</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-service-knoxidf</artifactId>
+        </dependency>
     </dependencies>
 </project>
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/UrlEncodedFormRequest.java b/gateway-server/src/main/java/org/apache/knox/gateway/UrlEncodedFormRequest.java
index 2e2482aa1..139e51862 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/UrlEncodedFormRequest.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/UrlEncodedFormRequest.java
@@ -17,17 +17,17 @@
  */
 package org.apache.knox.gateway;
 
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.eclipse.jetty.util.MultiMap;
+import org.eclipse.jetty.util.UrlEncoded;
+
+import javax.servlet.ServletRequest;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
 import java.io.IOException;
 import java.util.Enumeration;
 import java.util.Iterator;
 import java.util.Map;
-import javax.servlet.ServletRequest;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletRequestWrapper;
-
-import org.apache.knox.gateway.i18n.messages.MessagesFactory;
-import org.eclipse.jetty.util.MultiMap;
-import org.eclipse.jetty.util.UrlEncoded;
 
 /**
  * HttpServletRequest
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java
index 7a7afadd1..a9d544fe8 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/AbstractDataSourceFactory.java
@@ -42,6 +42,14 @@ public abstract class AbstractDataSourceFactory {
     public static final String DERBY_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME = "createKnoxProvidersTableDerby.sql";
     public static final String DERBY_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME = "createKnoxDescriptorsTableDerby.sql";
 
+    //KNOXIDF
+    public static final String KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityTable.sql";
+    public static final String KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityAttributesTable.sql";
+    public static final String ORACLE_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityTableOracle.sql";
+    public static final String ORACLE_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityAttributesTableOracle.sql";
+    public static final String DERBY_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityTableDerby.sql";
+    public static final String DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME = "createKnoxIDFFederatedIdentityAttributesTableDerby.sql";
+
     public static final String DATABASE_USER_ALIAS_NAME = "gateway_database_user";
     public static final String DATABASE_PASSWORD_ALIAS_NAME = "gateway_database_password";
     public static final String DATABASE_TRUSTSTORE_PASSWORD_ALIAS_NAME = "gateway_database_ssl_truststore_password";
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
index 200987278..3f627bdac 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java
@@ -22,37 +22,50 @@ public enum DatabaseType {
             AbstractDataSourceFactory.POSTGRES_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.POSTGRES_TOKEN_METADATA_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
     ),
     MYSQL("mysql",
             AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.TOKEN_METADATA_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
     ),
     MARIADB("mariadb",
             AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.TOKEN_METADATA_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
     ),
     HSQL("hsql",
             AbstractDataSourceFactory.TOKENS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.TOKEN_METADATA_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
     ),
     DERBY("derbydb",
             AbstractDataSourceFactory.DERBY_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.DERBY_TOKEN_METADATA_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.DERBY_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.DERBY_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.DERBY_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.DERBY_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
+
     ),
     ORACLE("oracle",
             AbstractDataSourceFactory.ORACLE_TOKENS_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.ORACLE_TOKEN_METADATA_TABLE_CREATE_SQL_FILE_NAME,
             AbstractDataSourceFactory.ORACLE_KNOX_PROVIDERS_TABLE_CREATE_SQL_FILE_NAME,
-            AbstractDataSourceFactory.ORACLE_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME
+            AbstractDataSourceFactory.ORACLE_KNOX_DESCRIPTORS_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_TABLE_CREATE_SQL_FILE_NAME,
+            AbstractDataSourceFactory.ORACLE_KNOXIDF_FED_IDENTITY_ATTR_TABLE_CREATE_SQL_FILE_NAME
     );
 
     private final String type;
@@ -60,13 +73,17 @@ public enum DatabaseType {
     private final String metadataTableSql;
     private final String providersTableSql;
     private final String descriptorsTableSql;
+    private final String federatedIdentityTableSql;
+    private final String federatedIdentityAttrTableSql;
 
-    DatabaseType(String type, String tokensTableSql, String metadataTableSql,  String providersTableSql, String descriptorsTableSql) {
+    DatabaseType(String type, String tokensTableSql, String metadataTableSql, String providersTableSql, String descriptorsTableSql, String federatedIdentityTableSql, String federatedIdentityAttrTableSql) {
         this.type = type;
         this.tokensTableSql = tokensTableSql;
         this.metadataTableSql = metadataTableSql;
         this.providersTableSql = providersTableSql;
         this.descriptorsTableSql = descriptorsTableSql;
+        this.federatedIdentityTableSql = federatedIdentityTableSql;
+        this.federatedIdentityAttrTableSql = federatedIdentityAttrTableSql;
     }
 
     public String type() {
@@ -89,6 +106,14 @@ public enum DatabaseType {
         return descriptorsTableSql;
     }
 
+    public String federatedIdentityTableSql() {
+        return federatedIdentityTableSql;
+    }
+
+    public String federatedIdentityAttrTableSql() {
+        return federatedIdentityAttrTableSql;
+    }
+
     public static DatabaseType fromString(String dbType) {
         for (DatabaseType dt : values()) {
             if (dt.type.equalsIgnoreCase(dbType)) {
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java
similarity index 56%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java
index 17e82e0af..261b36899 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java
@@ -5,19 +5,32 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.database;
 
-public enum TokenMetadataType {
+import org.apache.knox.gateway.services.token.impl.TokenStateDatabase;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+import javax.sql.DataSource;
 
+public class KnoxDatabase {
+
+    protected final DataSource dataSource;
+
+    public KnoxDatabase(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    protected void createTableIfNotExists(String tableName, String createSqlFileName) throws Exception {
+        if (!JDBCUtils.tableExists(tableName, dataSource)) {
+            JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, TokenStateDatabase.class.getClassLoader());
+        }
+    }
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/deploy/DeploymentFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/deploy/DeploymentFactory.java
index dfe4a4ea9..564f79300 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/deploy/DeploymentFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/deploy/DeploymentFactory.java
@@ -376,6 +376,13 @@ public abstract class DeploymentFactory {
       GatewayConfig gatewayConfig) {
     WebAppDescriptor wad = context.getWebAppDescriptor();
     String topoName = context.getTopology().getName();
+
+    final boolean hasKnoxIdf = services!= null && services.entrySet().stream().anyMatch( e -> e.getKey().equalsIgnoreCase("KNOXIDF") );
+    if (hasKnoxIdf) {
+      wad.createServlet().servletName("auth-consent-redirect").servletClass("org.apache.knox.gateway.service.knoxidf.AuthConsentServlet");
+      wad.createServletMapping().servletName("auth-consent-redirect").urlPattern("/authConsent");
+    }
+
     boolean asyncSupported = gatewayConfig.isAsyncSupported() || gatewayConfig.isTopologyAsyncSupported(topoName);
     if( applications == null ) {
       String servletName = topoName + SERVLET_NAME_SUFFIX;
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
index a38026f9a..2d7d12f13 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/DefaultGatewayServices.java
@@ -86,6 +86,8 @@ public class DefaultGatewayServices extends AbstractGatewayServices {
     addService(ServiceType.LDAP_ROLES_LOOKUP_SERVICE,  gatewayServiceFactory.create(this, ServiceType.LDAP_ROLES_LOOKUP_SERVICE, config, options));
 
     addService(ServiceType.LDAP_SERVICE, gatewayServiceFactory.create(this, ServiceType.LDAP_SERVICE, config, options));
+
+    addService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, gatewayServiceFactory.create(this, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options));
   }
 
   @Override
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
new file mode 100644
index 000000000..f78f96af6
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.factory;
+
+import org.apache.knox.gateway.GatewayMessages;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.Service;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentitityService;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
+import org.apache.knox.gateway.services.knoxidf.federation.JdbcFederatedIdentityService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+public class FederatedIdentityServiceFactory extends AbstractServiceFactory {
+
+    private static final GatewayMessages LOG = MessagesFactory.get(GatewayMessages.class);
+    private static final String DEFAULT_IMPLEMENTATION = EmptyFederatedIdentitityService.class.getName();
+
+    @Override
+    protected Service createService(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map<String, String> options, String implementation)
+            throws ServiceLifecycleException {
+
+        String implementationToUse = implementation;
+        // If implementation is empty, check if we should auto-enable JdbcFederatedIdentityService
+        if (isEmptyDefaultImplementation(implementationToUse)) {
+            if (isKnoxIdfEnabledInAnyTopology(gatewayServices)) {
+                implementationToUse = JdbcFederatedIdentityService.class.getName();
+            }
+        }
+
+        FederatedIdentityService service = null;
+        if (shouldCreateService(implementationToUse)) {
+            if (matchesImplementation(implementationToUse, EmptyFederatedIdentitityService.class, true)) {
+                service = new EmptyFederatedIdentitityService();
+            } else if (matchesImplementation(implementationToUse, JdbcFederatedIdentityService.class)) {
+                try {
+                    try {
+                        service = new JdbcFederatedIdentityService();
+                        ((JdbcFederatedIdentityService) service).setAliasService(getAliasService(gatewayServices));
+                        service.init(gatewayConfig, options);
+                    } catch (ServiceLifecycleException e) {
+                        LOG.errorInitializingService(implementationToUse, e.getMessage(), e);
+                        service =  new EmptyFederatedIdentitityService();
+                    }
+                } catch (Exception e) {
+                    throw new ServiceLifecycleException("Error while creating Federated Identity Service: " + e, e);
+                }
+            }
+            logServiceUsage(service.getClass().getName(), serviceType);
+        }
+        return service;
+    }
+
+    private boolean isKnoxIdfEnabledInAnyTopology(GatewayServices gatewayServices) {
+        final TopologyService topologyService = gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE);
+        if (topologyService != null) {
+            for (Topology topology : topologyService.getTopologies()) {
+                if (topology.getServices().stream().anyMatch(service -> "KNOXIDF".equals(service.getRole()))) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    @Override
+    protected ServiceType getServiceType() {
+        return ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE;
+    }
+
+    @Override
+    protected Collection<String> getKnownImplementations() {
+        return List.of(DEFAULT_IMPLEMENTATION, JdbcFederatedIdentityService.class.getName());
+    }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java
new file mode 100644
index 000000000..8ce9e550e
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+
+import java.util.Map;
+import java.util.Optional;
+
+public class EmptyFederatedIdentitityService implements FederatedIdentityService {
+    @Override
+    public void addFederatedIdentity(FederatedIdentity identity) {
+    }
+
+    @Override
+    public Optional<FederatedIdentity> findById(String identityId) {
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<FederatedIdentity> findByProviderAndSubject(String provider, String externalIssuer, String externalSubject) {
+        return Optional.empty();
+    }
+
+    @Override
+    public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
+    }
+
+    @Override
+    public void start() throws ServiceLifecycleException {
+    }
+
+    @Override
+    public void stop() throws ServiceLifecycleException {
+    }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java
new file mode 100644
index 000000000..649bf3038
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.database.KnoxDatabase;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Optional;
+
+class FederatedIdentityDatabase extends KnoxDatabase {
+    private static final String FEDERATED_IDENTITY_TABLE_NAME = "federated_identity";
+    private static final String FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME = "federated_identity_attr";
+    private static final String ADD_FEDERATED_IDENTITY_SQL = "INSERT INTO " + FEDERATED_IDENTITY_TABLE_NAME
+            + " (id, user_id, provider, external_subject, external_issuer, created_at) VALUES (?, ?, ?, ?, ?, ?)";
+    private static final String ADD_FEDERATED_IDENTITY_ATTR_SQL = "INSERT INTO " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME +
+            " (identity_id, attr_key, attr_value) VALUES (?, ?, ?)";
+    private static final String FETCH_FEDERATED_IDENTITY_BY_PROV_ISS_SUB_SQL = "SELECT * FROM " + FEDERATED_IDENTITY_TABLE_NAME +
+            " WHERE provider = ? AND external_issuer = ? AND external_subject = ?";
+    private static final String FETCH_FEDERATED_IDENTITY_SQL_BY_ID = "SELECT id, user_id, provider, external_subject, external_issuer, created_at FROM "
+            + FEDERATED_IDENTITY_TABLE_NAME + " WHERE id = ?";
+    private static final String FETCH_FEDERATED_IDENTITY_ATTR_SQL = "SELECT attr_key, attr_value FROM " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME + " WHERE identity_id = ?";
+
+    FederatedIdentityDatabase(DataSource dataSource, String dbType) throws Exception {
+        super(dataSource);
+        DatabaseType databaseType = DatabaseType.fromString(dbType);
+        createTableIfNotExists(FEDERATED_IDENTITY_TABLE_NAME, databaseType.federatedIdentityTableSql());
+        createTableIfNotExists(FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME, databaseType.federatedIdentityAttrTableSql());
+    }
+
+    void addFederatedIdentity(FederatedIdentity identity) throws SQLException {
+        // save core metadata first
+        try (Connection connection = dataSource.getConnection(); PreparedStatement addFederatedIdentityStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_SQL)) {
+            addFederatedIdentityStatement.setString(1, identity.getId());
+            addFederatedIdentityStatement.setString(2, identity.getUserId());
+            addFederatedIdentityStatement.setString(3, identity.getProvider());
+            addFederatedIdentityStatement.setString(4, identity.getExternalSubject());
+            addFederatedIdentityStatement.setString(5, identity.getExternalIssuer());
+            addFederatedIdentityStatement.setTimestamp(6, Timestamp.from(identity.getCreatedAt()));
+            addFederatedIdentityStatement.executeUpdate();
+        }
+
+        // save attributes
+        try (Connection connection = dataSource.getConnection(); PreparedStatement addFederatedIdentityAttrStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_ATTR_SQL)) {
+            for (var attribute : identity.getAttributes().entrySet()) {
+                addFederatedIdentityAttrStatement.setString(1, identity.getId());
+                addFederatedIdentityAttrStatement.setString(2, attribute.getKey());
+                addFederatedIdentityAttrStatement.setString(3, attribute.getValue());
+                addFederatedIdentityAttrStatement.addBatch();
+            }
+            addFederatedIdentityAttrStatement.executeBatch();
+        }
+    }
+
+
+    Optional<FederatedIdentity> findByProviderAndSubject(String provider, String issuer, String subject) throws SQLException {
+        FederatedIdentity federatedIdentity = null;
+        try (Connection connection = dataSource.getConnection(); PreparedStatement getFederatedIdentityStatement = connection.prepareStatement(FETCH_FEDERATED_IDENTITY_BY_PROV_ISS_SUB_SQL)) {
+            getFederatedIdentityStatement.setString(1, provider);
+            getFederatedIdentityStatement.setString(2, issuer);
+            getFederatedIdentityStatement.setString(3, subject);
+            try (ResultSet rs = getFederatedIdentityStatement.executeQuery()) {
+                if (rs.next()) {
+                    federatedIdentity = new FederatedIdentity(
+                            rs.getString("id"),
+                            rs.getString("user_id"),
+                            provider,
+                            subject,
+                            issuer,
+                            rs.getTimestamp("created_at").toInstant(), new HashMap<>());
+                } else {
+                    return Optional.empty();
+                }
+            }
+        }
+        populateAttributes(federatedIdentity);
+        return Optional.of(federatedIdentity);
+    }
+
+    Optional<FederatedIdentity> findById(String id) throws SQLException {
+        FederatedIdentity federatedIdentity = null;
+        try (Connection connection = dataSource.getConnection(); PreparedStatement getFederatedIdentityStatement = connection.prepareStatement(FETCH_FEDERATED_IDENTITY_SQL_BY_ID)) {
+            getFederatedIdentityStatement.setString(1, id);
+            try (ResultSet rs = getFederatedIdentityStatement.executeQuery()) {
+                if (rs.next()) {
+                    federatedIdentity = new FederatedIdentity(
+                            id,
+                            rs.getString("user_id"),
+                            rs.getString("provider"),
+                            rs.getString("external_subject"),
+                            rs.getString("external_issuer"),
+                            rs.getTimestamp("created_at").toInstant(), new HashMap<>());
+                } else {
+                    return Optional.empty();
+                }
+            }
+        }
+        populateAttributes(federatedIdentity);
+        return Optional.of(federatedIdentity);
+    }
+
+    private void populateAttributes(FederatedIdentity federatedIdentity) throws SQLException {
+        try (Connection connection = dataSource.getConnection(); PreparedStatement getFederatedIdentityAttrStatement = connection.prepareStatement(FETCH_FEDERATED_IDENTITY_ATTR_SQL)) {
+            getFederatedIdentityAttrStatement.setString(1, federatedIdentity.getId());
+            try (ResultSet rs = getFederatedIdentityAttrStatement.executeQuery()) {
+                while (rs.next()) {
+                    federatedIdentity.getAttributes().put(rs.getString(1), rs.getString(2));
+                }
+            }
+        }
+    }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java
new file mode 100644
index 000000000..d6f820a1d
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import org.apache.knox.gateway.i18n.messages.Message;
+import org.apache.knox.gateway.i18n.messages.MessageLevel;
+import org.apache.knox.gateway.i18n.messages.Messages;
+import org.apache.knox.gateway.i18n.messages.StackTrace;
+
+@Messages(logger="org.apache.knox.gateway.knoxidf.federated.identity.service")
+public interface FederatedIdentityServiceMessages {
+
+    @Message(level = MessageLevel.ERROR, text = "An error occurred while saving federated identity {0} in the database : {1}")
+    void errorSavingFederatedIdentityInDatabase(String federatedIdentityId, String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+    @Message(level = MessageLevel.ERROR, text = "An error occurred while fetching federated identity ({0} / {1} / {2}) from the database : {3}")
+    void errorFetchingFederatedIdentityFromDatabase(String provider, String issuer, String subject, String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+    @Message(level = MessageLevel.ERROR, text = "An error occurred while fetching federated identity ({0}) from the database : {1}")
+    void errorFetchingFederatedIdentityFromDatabase(String id, String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java
new file mode 100644
index 000000000..8e26bdc1d
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.database.DataSourceProvider;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.AliasService;
+
+import java.sql.SQLException;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+public class JdbcFederatedIdentityService implements FederatedIdentityService {
+    private static final FederatedIdentityServiceMessages LOG = MessagesFactory.get(FederatedIdentityServiceMessages.class);
+
+    private final AtomicBoolean initialized = new AtomicBoolean(false);
+    private final Lock initLock = new ReentrantLock(true);
+    private AliasService aliasService; // connection username/pw are stored here
+    private FederatedIdentityDatabase federatedIdentityDatabase;
+
+    @Override
+    public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
+        if (!initialized.get()) {
+            initLock.lock();
+            try {
+                if (aliasService == null) {
+                    throw new ServiceLifecycleException("The required AliasService reference has not been set.");
+                }
+                try {
+                    this.federatedIdentityDatabase = new FederatedIdentityDatabase(DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
+                    initialized.set(true);
+                } catch (Exception e) {
+                    throw new ServiceLifecycleException("Error while initiating JDBCTokenStateService: " + e, e);
+                }
+            } finally {
+                initLock.unlock();
+            }
+        }
+    }
+
+    @Override
+    public void start() throws ServiceLifecycleException {
+    }
+
+    @Override
+    public void stop() throws ServiceLifecycleException {
+    }
+
+    public void setAliasService(AliasService aliasService) {
+        this.aliasService = aliasService;
+    }
+
+    protected AliasService getAliasService() {
+        return aliasService;
+    }
+
+    @Override
+    public void addFederatedIdentity(FederatedIdentity identity) {
+        try {
+            if (findByProviderAndSubject(identity.getProvider(), identity.getExternalIssuer(), identity.getExternalSubject()).isEmpty()) {
+                federatedIdentityDatabase.addFederatedIdentity(identity);
+            }
+        } catch (SQLException e) {
+            LOG.errorSavingFederatedIdentityInDatabase(identity.getId(), e.getMessage(), e);
+            throw new FederatedIdentityServiceException("An error occurred while saving Federated Identity " + identity.getId() + " in the database", e);
+        }
+    }
+
+    @Override
+    public Optional<FederatedIdentity> findByProviderAndSubject(String provider, String issuer, String subject) {
+        try {
+            return federatedIdentityDatabase.findByProviderAndSubject(provider, issuer, subject);
+        } catch (SQLException e) {
+            LOG.errorFetchingFederatedIdentityFromDatabase(provider, subject, issuer, e.getMessage(), e);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<FederatedIdentity> findById(String id) {
+        try {
+            return federatedIdentityDatabase.findById(id);
+        } catch (SQLException e) {
+            LOG.errorFetchingFederatedIdentityFromDatabase(id, e.getMessage(), e);
+        }
+        return Optional.empty();
+    }
+
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateDatabase.java
index dbc89d695..c7579a480 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateDatabase.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateDatabase.java
@@ -19,7 +19,7 @@ package org.apache.knox.gateway.services.token.impl;
 
 import org.apache.commons.codec.binary.Base64;
 import org.apache.knox.gateway.database.DatabaseType;
-import org.apache.knox.gateway.database.JDBCUtils;
+import org.apache.knox.gateway.database.KnoxDatabase;
 import org.apache.knox.gateway.services.security.token.KnoxToken;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
 
@@ -37,7 +37,7 @@ import java.util.Set;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-public class TokenStateDatabase {
+public class TokenStateDatabase extends KnoxDatabase {
   static final String TOKENS_TABLE_NAME = "KNOX_TOKENS";
   static final String TOKEN_METADATA_TABLE_NAME = "KNOX_TOKEN_METADATA";
   private static final String ADD_TOKEN_SQL = "INSERT INTO " + TOKENS_TABLE_NAME + "(token_id, issue_time, expiration, max_lifetime) VALUES(?, ?, ?, ?)";
@@ -58,21 +58,13 @@ public class TokenStateDatabase {
   private static final String GET_TOKENS_CREATED_BY_USER_NAME_SQL = GET_ALL_TOKENS_SQL + " AND kt.token_id IN (SELECT token_id FROM " + TOKEN_METADATA_TABLE_NAME + " WHERE md_name = '" + TokenMetadata.CREATED_BY + "' AND md_value = ? )"
       + " ORDER BY kt.issue_time";
 
-  private final DataSource dataSource;
-
   TokenStateDatabase(DataSource dataSource, String dbType) throws Exception {
-    this.dataSource = dataSource;
+    super(dataSource);
     DatabaseType databaseType = DatabaseType.fromString(dbType);
     createTableIfNotExists(TOKENS_TABLE_NAME, databaseType.tokensTableSql());
     createTableIfNotExists(TOKEN_METADATA_TABLE_NAME, databaseType.metadataTableSql());
   }
 
-  private void createTableIfNotExists(String tableName, String createSqlFileName) throws Exception {
-    if (!JDBCUtils.tableExists(tableName, dataSource)) {
-      JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, TokenStateDatabase.class.getClassLoader());
-    }
-  }
-
   boolean addToken(String tokenId, long issueTime, long expiration, long maxLifetimeDuration) throws SQLException {
     try (Connection connection = dataSource.getConnection(); PreparedStatement addTokenStatement = connection.prepareStatement(ADD_TOKEN_SQL)) {
       addTokenStatement.setString(1, tokenId);
diff --git a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
index bd808747c..93bc7845a 100644
--- a/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
+++ b/gateway-server/src/main/resources/META-INF/services/org.apache.knox.gateway.services.ServiceFactory
@@ -16,9 +16,14 @@
 # limitations under the License.
 ##########################################################################
 
+# Please keep the alphabetical order of service factories!
+
 org.apache.knox.gateway.services.factory.AliasServiceFactory
+org.apache.knox.gateway.services.factory.ConcurrentSessionVerifierFactory
 org.apache.knox.gateway.services.factory.ClusterConfigurationMonitorServiceFactory
 org.apache.knox.gateway.services.factory.CryptoServiceFactory
+org.apache.knox.gateway.services.factory.FederatedIdentityServiceFactory
+org.apache.knox.gateway.services.factory.GatewayStatusServiceFactory
 org.apache.knox.gateway.services.factory.HostMappingServiceFactory
 org.apache.knox.gateway.services.factory.KeystoreServiceFactory
 org.apache.knox.gateway.services.factory.MasterServiceFactory
@@ -28,10 +33,8 @@ org.apache.knox.gateway.services.factory.ServerInfoServiceFactory
 org.apache.knox.gateway.services.factory.ServiceDefinitionRegistryFactory
 org.apache.knox.gateway.services.factory.ServiceRegistryServiceFactory
 org.apache.knox.gateway.services.factory.SslServiceFactory
-org.apache.knox.gateway.services.factory.TokenServiceFactory
 org.apache.knox.gateway.services.factory.TokenStateServiceFactory
 org.apache.knox.gateway.services.factory.TopologyServiceFactory
-org.apache.knox.gateway.services.factory.ConcurrentSessionVerifierFactory
-org.apache.knox.gateway.services.factory.GatewayStatusServiceFactory
 org.apache.knox.gateway.services.factory.LdapServiceFactory
 org.apache.knox.gateway.services.factory.LDAPRolesLookupServiceFactory
+org.apache.knox.gateway.services.factory.TokenServiceFactory
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql
new file mode 100644
index 000000000..239cb5df1
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql
@@ -0,0 +1,21 @@
+--  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.
+CREATE TABLE FEDERATED_IDENTITY_ATTR (
+    identity_id VARCHAR(36)  NOT NULL,
+    attr_key    VARCHAR(128) NOT NULL,
+    attr_value  TEXT,
+    PRIMARY KEY (identity_id, attr_key),
+    FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY (id) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
new file mode 100644
index 000000000..13ae9ab11
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql
@@ -0,0 +1,22 @@
+--  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.
+
+CREATE TABLE FEDERATED_IDENTITY_ATTR (
+    identity_id VARCHAR(36),
+    attr_key    VARCHAR(128),
+    attr_value  CLOB,
+    PRIMARY KEY (identity_id, attr_key),
+    CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql
new file mode 100644
index 000000000..7ed07b1b0
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql
@@ -0,0 +1,22 @@
+--  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.
+
+CREATE TABLE FEDERATED_IDENTITY_ATTR (
+    identity_id VARCHAR2(36) NOT NULL,
+    attr_key    VARCHAR2(128) NOT NULL,
+    attr_value  CLOB,
+    CONSTRAINT pk_fed_attr PRIMARY KEY (identity_id, attr_key),
+    CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql
new file mode 100644
index 000000000..acaf1c340
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql
@@ -0,0 +1,25 @@
+--  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.
+
+CREATE TABLE FEDERATED_IDENTITY (
+    id               VARCHAR(36) PRIMARY KEY,
+    user_id          VARCHAR(36)  NOT NULL,
+    provider         VARCHAR(64)  NOT NULL,
+    external_subject VARCHAR(255) NOT NULL,
+    external_issuer  VARCHAR(255) NOT NULL,
+    created_at       TIMESTAMP    NOT NULL
+);
+
+CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
new file mode 100644
index 000000000..7152d4c71
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql
@@ -0,0 +1,25 @@
+--  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.
+
+CREATE TABLE FEDERATED_IDENTITY (
+    id               VARCHAR(36) PRIMARY KEY,
+    user_id          VARCHAR(36),
+    provider         VARCHAR(64),
+    external_subject VARCHAR(255),
+    external_issuer  VARCHAR(255),
+    created_at       TIMESTAMP
+);
+
+CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql
new file mode 100644
index 000000000..0dc42c1f7
--- /dev/null
+++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql
@@ -0,0 +1,25 @@
+--  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.
+
+CREATE TABLE FEDERATED_IDENTITY (
+    id               VARCHAR2(36) PRIMARY KEY,
+    user_id          VARCHAR2(36) NOT NULL,
+    provider         VARCHAR2(64) NOT NULL,
+    external_subject VARCHAR2(255) NOT NULL,
+    external_issuer  VARCHAR2(255) NOT NULL,
+    created_at       TIMESTAMP NOT NULL
+);
+
+CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject);
\ No newline at end of file
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java
index ff58ad6f9..57271324c 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java
@@ -67,7 +67,8 @@ public class AbstractGatewayServicesTest {
         ServiceType.REMOTE_CONFIGURATION_MONITOR,
         ServiceType.GATEWAY_STATUS_SERVICE,
         ServiceType.LDAP_SERVICE,
-        ServiceType.LDAP_ROLES_LOOKUP_SERVICE
+        ServiceType.LDAP_ROLES_LOOKUP_SERVICE,
+        ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE
     };
 
     assertNotEquals(ServiceType.values(), orderedServiceTypes);
diff --git a/gateway-util-common/pom.xml b/gateway-service-knoxidf/pom.xml
similarity index 63%
copy from gateway-util-common/pom.xml
copy to gateway-service-knoxidf/pom.xml
index 1eec58fe9..48296b435 100644
--- a/gateway-util-common/pom.xml
+++ b/gateway-service-knoxidf/pom.xml
@@ -24,99 +24,92 @@
         <artifactId>gateway</artifactId>
         <version>3.0.0-SNAPSHOT</version>
     </parent>
-    
-    <artifactId>gateway-util-common</artifactId>
-    <name>gateway-util-common</name>
-    <description>A collection of common utilities.</description>
+
+    <artifactId>gateway-service-knoxidf</artifactId>
+    <name>gateway-service-knoxidf</name>
 
     <dependencies>
         <dependency>
             <groupId>org.apache.knox</groupId>
             <artifactId>gateway-i18n</artifactId>
         </dependency>
-
         <dependency>
-            <groupId>org.bouncycastle</groupId>
-            <artifactId>bcprov-jdk18on</artifactId>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-spi</artifactId>
         </dependency>
-
         <dependency>
-            <groupId>org.bouncycastle</groupId>
-            <artifactId>bcpkix-jdk18on</artifactId>
-        </dependency>
-
-        <dependency>
-            <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-core</artifactId>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-provider-jersey</artifactId>
         </dependency>
         <dependency>
-            <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-databind</artifactId>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-util-common</artifactId>
         </dependency>
         <dependency>
-            <groupId>com.fasterxml.jackson.datatype</groupId>
-            <artifactId>jackson-datatype-jsr310</artifactId>
+            <groupId>org.apache.knox</groupId>
+            <artifactId>gateway-service-knoxtoken</artifactId>
         </dependency>
-        
+
         <dependency>
-            <groupId>commons-codec</groupId>
-            <artifactId>commons-codec</artifactId>
+            <groupId>javax.annotation</groupId>
+            <artifactId>javax.annotation-api</artifactId>
         </dependency>
-
         <dependency>
-            <groupId>org.apache.commons</groupId>
-            <artifactId>commons-lang3</artifactId>
+            <groupId>javax.ws.rs</groupId>
+            <artifactId>javax.ws.rs-api</artifactId>
         </dependency>
-
         <dependency>
             <groupId>javax.servlet</groupId>
             <artifactId>javax.servlet-api</artifactId>
         </dependency>
 
         <dependency>
-            <groupId>com.sun.activation</groupId>
-            <artifactId>jakarta.activation</artifactId>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
         </dependency>
-
         <dependency>
-            <groupId>jakarta.activation</groupId>
-            <artifactId>jakarta.activation-api</artifactId>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-core</artifactId>
         </dependency>
-
         <dependency>
-            <groupId>org.apache.directory.server</groupId>
-            <artifactId>apacheds-jdbm</artifactId>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
         </dependency>
-
         <dependency>
-            <groupId>org.apache.logging.log4j</groupId>
-            <artifactId>log4j-api</artifactId>
+            <groupId>com.fasterxml.uuid</groupId>
+            <artifactId>java-uuid-generator</artifactId>
         </dependency>
         <dependency>
-            <groupId>org.apache.logging.log4j</groupId>
-            <artifactId>log4j-core</artifactId>
+            <groupId>com.nimbusds</groupId>
+            <artifactId>nimbus-jose-jwt</artifactId>
         </dependency>
         <dependency>
-            <groupId>org.apache.logging.log4j</groupId>
-            <artifactId>log4j-slf4j2-impl</artifactId>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-text</artifactId>
         </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpclient</artifactId>
         </dependency>
-        <!-- ********** ********** ********** ********** ********** ********** -->
-        <!-- ********** Test Dependencies                           ********** -->
-        <!-- ********** ********** ********** ********** ********** ********** -->
-
         <dependency>
-            <groupId>org.apache.knox</groupId>
-            <artifactId>gateway-test-utils</artifactId>
-            <scope>test</scope>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpcore</artifactId>
         </dependency>
         <dependency>
-            <groupId>commons-io</groupId>
-            <artifactId>commons-io</artifactId>
-            <scope>test</scope>
+            <groupId>org.glassfish.jersey.core</groupId>
+            <artifactId>jersey-common</artifactId>
         </dependency>
     </dependencies>
 </project>
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java
new file mode 100644
index 000000000..6200edc83
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.UriInfo;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.getRequestParamSafe;
+
+
+public class AuthConsentServlet extends HttpServlet {
+
+    @Context
+    UriInfo uriInfo;
+
+    @Override
+    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        response.setContentType("text/html;charset=UTF-8");
+        final String clientId = getRequestParamSafe(request, "client_id");
+        final String state = getRequestParamSafe(request, "state");
+        final String scope = getRequestParamSafe(request, "scope");
+        final Set<String> scopes = new HashSet<>(Arrays.asList(scope.split("\\s+")));
+
+        try (PrintWriter out = response.getWriter()) {
+            out.println("<!DOCTYPE html>");
+            out.println("<html><head><title>Consent Required</title>");
+            out.println("<style>");
+            out.println("body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f6f8; padding: 40px; margin: 0; }");
+            out.println(".container { background: #fff; padding: 30px 40px; border-radius: 10px; max-width: 480px; width: 100%; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin: 0 auto; }");
+            out.println("h2 { color: #333; margin-bottom: 20px; }");
+            out.println("p { font-size: 15px; color: #555; margin-bottom: 20px; }");
+            out.println("ul { text-align: left; padding-left: 20px; margin-bottom: 30px; }");
+            out.println("li { margin-bottom: 8px; }");
+            out.println("button { padding: 10px 24px; margin: 0 10px; font-size: 15px; cursor: pointer; border-radius: 6px; border: none; transition: background-color 0.25s ease; }");
+            out.println(".accept { background: #007bff; color: #fff; }");
+            out.println(".accept:hover { background: #0069d9; }");
+            out.println(".deny { background: #e0e0e0; color: #333; }");
+            out.println(".deny:hover { background: #d0d0d0; }");
+            out.println("</style>");
+            out.println("</head><body>");
+            out.println("<div class='container'>");
+            out.println("<h2>Application Consent Required</h2>");
+            out.printf(Locale.US, "<p>The application <b>%s</b> is requesting access to your account.</p>%n", clientId);
+
+            if (!scopes.isEmpty()) {
+                out.println("<p>This application will be able to:</p>");
+                out.println("<ul>");
+                for (String s : scopes) {
+                    out.printf(Locale.US, "<li>%s</li>%n", describeScope(s));
+                }
+                out.println("</ul>");
+            }
+
+            out.println("<form method='post' onsubmit='return confirmAction();'>");
+            out.printf(Locale.US, "<input type='hidden' name='state' value='%s'/>%n", state);
+            out.println("<div style='display: flex; justify-content: center; gap: 20px;'>");
+            out.println("<button type='submit' name='action' value='accept' class='accept'>Accept</button>");
+            out.println("<button type='submit' name='action' value='deny' class='deny'>Deny</button>");
+            out.println("</div>");
+            out.println("</form>");
+            out.println("</div>");
+            out.println("<script>");
+            out.println("function confirmAction() {");
+            out.println("  const action = event.submitter.value;");
+            out.println("  if (action === 'accept') return confirm('Do you want to grant consent to this application?');");
+            out.println("  return true;");
+            out.println("}");
+            out.println("</script>");
+            out.println("</body></html>");
+        }
+    }
+
+    private String describeScope(String scope) {
+        if (scope == null) {
+            return "";
+        }
+
+        switch (scope) {
+            case "openid":
+                return "Authenticate using your account";
+            case "profile":
+                return "View your basic profile information";
+            case "email":
+                return "View your email address";
+            case "address":
+                return "View your address information";
+            case "phone":
+                return "View your phone number";
+            case "calendar.read":
+                return "Read your calendar events";
+            case "calendar.write":
+                return "Modify your calendar events";
+            default:
+                return scope;
+        }
+    }
+
+    //Redirect target is application-local and state is encoded/controlled
+    @SuppressWarnings("UNVALIDATED_REDIRECT")
+    @Override
+    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        final String action = request.getParameter("action");
+        final String state = request.getParameter("state");
+        final String redirectUri = request.getServletContext().getContextPath() + "/" + AuthorizeResource.RESOURCE_PATH +
+                ("accept".equals(action) ? "/consentAccepted?state=" + state : "/consentDenied");
+        response.sendRedirect(redirectUri);
+    }
+
+}
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
new file mode 100644
index 000000000..94415de19
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -0,0 +1,408 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import com.fasterxml.uuid.Generators;
+import com.fasterxml.uuid.impl.NameBasedGenerator;
+import com.nimbusds.jose.KeyLengthException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
+import org.apache.knox.gateway.services.security.AliasServiceException;
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenMetadataType;
+import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.apache.knox.gateway.util.JsonUtils;
+import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadata;
+import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadataStore;
+import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration;
+import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
+import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.ALLOWED_SCOPES;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE_METHOD;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.DEFAULT_SCOPES;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.FEDERATED_IDENTITY_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.NONCE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.OFFLINE_ACCESS_SCOPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URIS;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.RESPONSE_TYPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.STATE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
+
+
+@Path(AuthorizeResource.RESOURCE_PATH)
+public class AuthorizeResource extends PasscodeTokenResourceBase {
+    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/authorize";
+    private static final UUID KNOX_NAMESPACE = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
+    private static final NameBasedGenerator UUID_V5 = Generators.nameBasedGenerator(KNOX_NAMESPACE);
+    public static final Set<String> ALLOWED_CLAIMS = Set.of("preferred_username", "email", "email_verified",
+            "given_name", "family_name", "name", "locale");
+
+    private static final String UTF_8 = StandardCharsets.UTF_8.name();
+    private AuthorizeRequestMetadataStore authorizeRequestMetadataStore;
+    private final FederatedOpConfigurationStore federatedOpConfigurationStore = FederatedOpConfigurationStore.getInstance(120000L);
+
+    @Context
+    private HttpServletRequest request;
+
+    @Context
+    private ServletContext servletContext;
+
+    private FederatedIdentityService federatedIdentityService;
+
+    @PostConstruct
+    @Override
+    public void init() throws ServletException, AliasServiceException, ServiceLifecycleException, KeyLengthException {
+        super.init();
+        this.authorizeRequestMetadataStore = AuthorizeRequestMetadataStore.getInstance(tokenTTL);
+        final GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+        federatedIdentityService = services.getService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE);
+    }
+
+    @Override
+    @GET
+    public Response doGet() {
+        return authorize();
+    }
+
+    @Override
+    @POST
+    public Response doPost() {
+        return authorize();
+    }
+
+    private Response authorize() {
+        return authorize(request.getParameter(RESPONSE_TYPE), request.getParameter(CLIENT_ID), request.getParameter(REDIRECT_URI),
+                request.getParameter(SCOPE), request.getParameter(STATE), request.getParameter(NONCE),
+                request.getParameter(CODE_CHALLENGE), request.getParameter(CODE_CHALLENGE_METHOD));
+    }
+
+    private Response authorize(String responseType,
+                               String clientId,
+                               String redirectUri,
+                               String scope,
+                               String state,
+                               String nonce,
+                               String codeChallenge,
+                               String codeChallengeMethod) {
+        final String subject = SubjectUtils.getCurrentEffectivePrincipalName();
+        final Set<String> requestedScopes = StringUtils.isBlank(scope) ? DEFAULT_SCOPES : new HashSet<>(Arrays.asList(scope.split("\\s+")));
+        final AuthorizeRequestMetadata authorizeRequestMetadata = new AuthorizeRequestMetadata(clientId, subject, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod);
+        final Response verificationErrorResponse = verifyParams(authorizeRequestMetadata);
+        if (verificationErrorResponse != null) {
+            return verificationErrorResponse;
+        }
+
+        if (!hasConsent(authorizeRequestMetadata)) {
+            if ("true".equalsIgnoreCase(request.getParameter("auto_consent"))) {
+                markConsentAccepted(authorizeRequestMetadata);
+            } else {
+                final String consentAuthState = UUID.randomUUID().toString();
+                authorizeRequestMetadataStore.put(consentAuthState, authorizeRequestMetadata);
+                final String baseUri = servletContext.getContextPath() + "/authConsent";
+                final String scopeParam = URLEncoder.encode(authorizeRequestMetadata.getJoinedRequestedScopes(), StandardCharsets.UTF_8);
+                final String redirect = String.format(Locale.US, "%s?client_id=%s&state=%s&scope=%s", baseUri, clientId, consentAuthState, scopeParam);
+                return Response.seeOther(java.net.URI.create(redirect)).build();
+            }
+        }
+        return getAuthCodeFromKnox(authorizeRequestMetadata, null);
+    }
+
+    private boolean hasConsent(final AuthorizeRequestMetadata authorizeRequestMetadata) {
+        try {
+            final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(authorizeRequestMetadata.getClientId());
+            final String consentKey = "consentAccepted_" + authorizeRequestMetadata.getSubject();
+            final String storedScopes = tokenMetadata.getMetadataMap().get(consentKey);
+            if (storedScopes == null || storedScopes.isEmpty()) {
+                return false;
+            }
+            final Set<String> storedScopeSet = new HashSet<>(Arrays.asList(storedScopes.split("\\s+")));
+            return storedScopeSet.containsAll(authorizeRequestMetadata.getRequestedScopes());
+        } catch (UnknownTokenException e) {
+            //this should not happen as we validated the client_id already
+            return false;
+        }
+    }
+
+    private void markConsentAccepted(AuthorizeRequestMetadata authorizeRequestMetadata) {
+        final TokenMetadata consentAcceptedMetadata = new TokenMetadata();
+        consentAcceptedMetadata.add("consentAccepted_" + authorizeRequestMetadata.getSubject(), authorizeRequestMetadata.getJoinedRequestedScopes());
+        tokenStateService.addMetadata(authorizeRequestMetadata.getClientId(), consentAcceptedMetadata);
+    }
+
+    private Response getAuthCodeFromKnox(final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair<String, String> federatedTokens) {
+        final Response tokenResponse = getAuthenticationToken();
+        if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) {
+            final Map<String, String> tokenResponseMap = JsonUtils.getMapFromJsonString(tokenResponse.getEntity().toString());
+            final String tokenId = tokenResponseMap.get(TOKEN_ID);
+            decorateAuthCodeToken(tokenId, authorizeRequestMetadata, federatedTokens);
+            return redirectToAuthSuccess(authorizeRequestMetadata, tokenId);
+        }
+        return tokenResponse;
+    }
+
+    private Response redirectToAuthSuccess(final AuthorizeRequestMetadata authorizeRequestMetadata, final String code) {
+        final String redirectLocation;
+        try {
+            redirectLocation = authorizeRequestMetadata.getRedirectUri()
+                    + "?code=" + URLEncoder.encode(code, UTF_8)
+                    + "&state=" + URLEncoder.encode(authorizeRequestMetadata.getState(), UTF_8);
+        } catch (UnsupportedEncodingException e) {
+            throw new RuntimeException(e); //This should never happen with UTF-8
+        }
+        return Response.seeOther(URI.create(redirectLocation)).build();
+    }
+
+    @GET
+    @Path("/callback")
+    public Response authCallback() throws Exception {
+        //This is the callback for the federated OP
+        final String federatedAuthCode = request.getParameter(CODE);
+        final String state = request.getParameter(STATE);
+        final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state);
+        //at this point, there has to be exactly 1 federated OP config
+        final FederatedOpConfiguration federatedOpConfiguration = federatedOpConfigurationStore.get(state).stream().findFirst().get();
+        final Pair<String, String> federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
+        final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedTokens.getLeft(), federatedOpConfiguration.getName());
+        return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight()));
+    }
+
+    @GET
+    @Path("/consentAccepted")
+    public Response consentAccepted() throws Exception {
+        final String state = request.getParameter(STATE);
+        final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state);
+        if (authorizeRequestMetadata == null) {
+            return error("Consent cannot be accepted", "Invalid state");
+        }
+        markConsentAccepted(authorizeRequestMetadata);
+        return authorize(authorizeRequestMetadata.getResponseType(),
+                authorizeRequestMetadata.getClientId(),
+                authorizeRequestMetadata.getRedirectUri(),
+                authorizeRequestMetadata.getJoinedRequestedScopes(),
+                authorizeRequestMetadata.getState(),
+                authorizeRequestMetadata.getNonce(),
+                authorizeRequestMetadata.getCodeChallenge(),
+                authorizeRequestMetadata.getCodeChallengeMethod());
+    }
+
+    @GET
+    @Path("/consentDenied")
+    public Response consentDenied() throws Exception {
+        return Response.status(Response.Status.FORBIDDEN).entity("Consent denied!").build();
+    }
+
+    private void decorateAuthCodeToken(final String tokenId, final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair<String, String> federatedTokens) {
+        final Map<String, String> authCodeTokenMap = new HashMap<>();
+        authCodeTokenMap.put(TokenMetadata.TYPE, TokenMetadataType.AUTH_CODE.name());
+        authCodeTokenMap.put(CLIENT_ID, authorizeRequestMetadata.getClientId());
+        authCodeTokenMap.put(REDIRECT_URI, authorizeRequestMetadata.getRedirectUri());
+        authCodeTokenMap.put(TokenMetadata.USER_NAME, authorizeRequestMetadata.getSubject());
+        authCodeTokenMap.put(SCOPE, authorizeRequestMetadata.getJoinedRequestedScopes());
+        if (authorizeRequestMetadata.getRequestedScopes().contains(OFFLINE_ACCESS_SCOPE)) {
+            authCodeTokenMap.put(OFFLINE_ACCESS_SCOPE, "true");
+        }
+        if (StringUtils.isNotBlank(authorizeRequestMetadata.getNonce())) {
+            authCodeTokenMap.put(NONCE, authorizeRequestMetadata.getNonce());
+        }
+        if (StringUtils.isNotBlank(authorizeRequestMetadata.getCodeChallenge())) {
+            authCodeTokenMap.put(CODE_CHALLENGE, authorizeRequestMetadata.getCodeChallenge());
+            authCodeTokenMap.put(CODE_CHALLENGE_METHOD, StringUtils.defaultIfBlank(authorizeRequestMetadata.getCodeChallengeMethod(), "plain"));
+        }
+        if (federatedTokens != null) {
+            authCodeTokenMap.put(FEDERATED_IDENTITY_ID, federatedTokens.getLeft());
+            authCodeTokenMap.putAll(KnoxIDFUtils.splitFederatedToken(federatedTokens.getRight(), false));
+        }
+        tokenStateService.addMetadata(tokenId, new TokenMetadata(authCodeTokenMap));
+    }
+
+    private Response verifyParams(final AuthorizeRequestMetadata authorizeRequestMetadata) {
+        final Response basicVerificationResponse = authorizeRequestMetadata.verify();
+        if (basicVerificationResponse == null) {
+            final TokenMetadata tokenMetadata;
+            // Verify client ID
+            try {
+                //This is ok for a POC, but we should cache that later
+                tokenMetadata = tokenStateService.getTokenMetadata(authorizeRequestMetadata.getClientId());
+            } catch (UnknownTokenException e) {
+                return error("invalid_request", "Unknown client_id");
+            }
+
+            // Verify redirect URI
+            final String storedRedirectUris = tokenMetadata.getMetadata(REDIRECT_URIS);
+            if (StringUtils.isBlank(storedRedirectUris)) {
+                return error("invalid_request", "Missing stored redirect_uris, cannot authorize the request");
+            }
+            final Set<String> registeredRedirectUris = new HashSet<>(Arrays.asList(storedRedirectUris.split(",")));
+            if (!matchesRedirectUri(authorizeRequestMetadata.getRedirectUri(), registeredRedirectUris)) {
+                return error("invalid_request", "Invalid redirect_uri");
+            }
+
+            // Verify scope(s)
+            final String storedAllowedScopes = tokenMetadata.getMetadata(ALLOWED_SCOPES);
+            if (StringUtils.isBlank(storedAllowedScopes)) {
+                return error("invalid_scope", "Missing stored allowed_scopes, cannot authorize the request");
+            }
+            final Set<String> registeredScopes = new HashSet<>(Arrays.asList(storedAllowedScopes.trim().split("\\s+")));
+            if (authorizeRequestMetadata.getRequestedScopes().stream().anyMatch(scope -> !registeredScopes.contains(scope))) {
+                return error("invalid_scope", "One or more requested scopes are not allowed");
+            }
+
+            return null;
+        }
+        return basicVerificationResponse;
+    }
+
+    private boolean matchesRedirectUri(String requestedUri, Set<String> registeredUris) {
+        for (String registered : registeredUris) {
+            if (registered.endsWith("*")) {
+                String prefix = registered.substring(0, registered.length() - 1);
+                if (requestedUri.startsWith(prefix)) {
+                    return true;
+                }
+            } else if (registered.equals(requestedUri)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Pair<String, String> exchangeFederatedAuthCodeToTokens(String federatedAuthCode, FederatedOpConfiguration opConfig) {
+        String federatedIdToken = null;
+        String federatedAccessToken = null;
+        final Response federatedTokenExchangeResponse = fetchFederatedTokens(federatedAuthCode, opConfig);
+        if (federatedTokenExchangeResponse.getStatus() == Response.Status.OK.getStatusCode()) {
+            final Map<String, String> federatedTokenExchangeResponseBodyMap = JsonUtils.getMapFromJsonString((String) federatedTokenExchangeResponse.getEntity());
+            federatedIdToken = federatedTokenExchangeResponseBodyMap.get("id_token");
+            federatedAccessToken = federatedTokenExchangeResponseBodyMap.get("access_token");
+            return Pair.of(federatedIdToken, federatedAccessToken);
+        } else {
+            throw new RuntimeException("Error fetching Federated Tokens from Federated Auth Code: " + federatedTokenExchangeResponse.getEntity());
+        }
+    }
+
+    private Response fetchFederatedTokens(final String code, FederatedOpConfiguration opConfig) {
+        final List<NameValuePair> params = new ArrayList<>();
+        params.add(new BasicNameValuePair(CODE, code));
+        params.add(new BasicNameValuePair(REDIRECT_URI, opConfig.getAuthorizeCallback()));
+        params.add(new BasicNameValuePair(GRANT_TYPE, "authorization_code"));
+        params.add(new BasicNameValuePair(CLIENT_ID, opConfig.getClientId()));
+        params.add(new BasicNameValuePair(CLIENT_SECRET, opConfig.getClientSecret()));
+
+        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+            HttpPost post = new HttpPost(opConfig.getTokenEndpoint());
+            post.setHeader("Content-Type", "application/x-www-form-urlencoded");
+            post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
+
+            try (CloseableHttpResponse response = httpClient.execute(post)) {
+                int status = response.getStatusLine().getStatusCode();
+                String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+                return Response.status(status).entity(body).build();
+            }
+        } catch (Exception e) {
+            return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("{\"error\":\"" + e.getMessage() + "\"}").build();
+        }
+    }
+
+    private FederatedIdentity resolveFederatedIdentity(String federatedIdToken, String opName) throws ParseException {
+        final JWT jwt = new JWTToken(federatedIdToken);
+        final String issuer = jwt.getIssuer();
+        final String subject = jwt.getSubject();
+        return federatedIdentityService.findByProviderAndSubject(opName.toUpperCase(Locale.US), issuer, subject).orElseGet(() -> persistFederatedIdentity(jwt, opName));
+    }
+
+    private FederatedIdentity persistFederatedIdentity(final JWT jwt, String opName) {
+        final Map<String, String> attributes = jwt.getJWTClaimsSet().getClaims().entrySet().stream()
+                .filter(e -> ALLOWED_CLAIMS.contains(e.getKey()))
+                .filter(e -> e.getValue() != null)
+                .collect(Collectors.toMap(
+                        Map.Entry::getKey,
+                        e -> String.valueOf(e.getValue()),
+                        (a, b) -> a,           // defensive: ignore duplicates
+                        HashMap::new
+                ));
+        final FederatedIdentity federatedIdentity = new FederatedIdentity(
+                deriveKnoxSubject(jwt.getSubject(), jwt.getIssuer()),  // internal user id (generated)
+                opName.toUpperCase(Locale.US),                         // provider
+                jwt.getSubject(),                                      // external subject
+                jwt.getIssuer(),                                       // external issuer
+                Instant.now(),                                         // createdAt
+                attributes
+        );
+
+        federatedIdentityService.addFederatedIdentity(federatedIdentity);
+
+        return federatedIdentity;
+    }
+
+    private String deriveKnoxSubject(String subject, String issuer) {
+        final String name = issuer + "|" + subject;
+        final UUID uuid = UUID_V5.generate(name.getBytes(StandardCharsets.UTF_8));
+        return uuid.toString();
+    }
+}
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
new file mode 100644
index 000000000..ce895549d
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.util.JsonUtils;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletContext;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_EXCHANGE_TOPOLOGY_NAME;
+
+@Path(BASE_RESORCE_PATH + "/.well-known/openid-configuration")
+@Produces(MediaType.APPLICATION_JSON)
+public class DiscoveryResource {
+    private String currentTopologyName;
+    private String tokenExchangeTopologyName;
+
+    @Context
+    private ServletContext servletContext;
+
+    @PostConstruct
+    public void init() {
+        tokenExchangeTopologyName = servletContext.getInitParameter(TOKEN_EXCHANGE_TOPOLOGY_NAME);
+        currentTopologyName = (String) servletContext.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE);
+    }
+
+    @GET
+    public Response getConfig(@Context UriInfo uriInfo) {
+        final String baseUrl = uriInfo.getBaseUri().toString();
+        final Map<String, Object> config = new HashMap<>();
+        config.put("issuer", baseUrl + "knoxidf");
+        config.put("authorization_endpoint", baseUrl + AuthorizeResource.RESOURCE_PATH);
+        String tokenEndpoint = baseUrl + TokenResource.RESOURCE_PATH;
+        String userInfoEndpoint = baseUrl + UserInfoResource.RESOURCE_PATH;
+        if (tokenExchangeTopologyName != null) {
+            tokenEndpoint = tokenEndpoint.replaceAll(currentTopologyName, tokenExchangeTopologyName);
+            userInfoEndpoint = userInfoEndpoint.replaceAll(currentTopologyName, tokenExchangeTopologyName);
+        }
+        config.put("token_endpoint", tokenEndpoint);
+        config.put("userinfo_endpoint", userInfoEndpoint);
+        config.put("jwks_uri", baseUrl + JwksResource.RESOURCE_PATH);
+        config.put("response_types_supported", new String[]{KnoxIDFConstants.CODE});
+        config.put("grant_types_supported", new String[]{KnoxIDFConstants.AUTH_CODE, KnoxIDFConstants.REFRESH_TOKEN});
+        config.put("scopes_supported", KnoxIDFConstants.DEFAULT_SCOPES);
+        config.put("id_token_signing_alg_values_supported", new String[]{"RS256"});
+        config.put("code_challenge_methods_supported", new String[]{KnoxIDFConstants.PKCE_METHOD_PLAIN, KnoxIDFConstants.PKCE_METHOD_S256});
+        return Response.ok(JsonUtils.renderAsJsonString(config)).build();
+    }
+
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java
similarity index 55%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java
index 17e82e0af..47f802b04 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java
@@ -5,19 +5,35 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.service.knoxidf;
 
-public enum TokenMetadataType {
+import org.apache.knox.gateway.service.knoxtoken.JWKSResource;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
 
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+
+@Path(JwksResource.RESOURCE_PATH)
+@Produces(MediaType.APPLICATION_JSON)
+public class JwksResource extends JWKSResource {
+    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/jwks";
+
+    @GET
+    public Response getKeys() {
+        return getJwksResponse();
+    }
 }
+
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/OIDCScope.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/OIDCScope.java
new file mode 100644
index 000000000..d673f3f45
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/OIDCScope.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+public enum OIDCScope {
+    OPENID(new HashSet<>(Collections.singletonList("sub"))),
+    PROFILE(new HashSet<>(Arrays.asList(
+            "name", "family_name", "given_name",
+            "middle_name", "nickname", "preferred_username",
+            "profile", "picture", "website", "gender",
+            "birthdate", "zoneinfo", "locale", "updated_at"
+    ))),
+    EMAIL(new HashSet<>(Arrays.asList("email", "email_verified"))),
+    ADDRESS(new HashSet<>(Collections.singletonList("address"))),
+    PHONE(new HashSet<>(Arrays.asList("phone_number", "phone_number_verified"))),
+    ROLES(new HashSet<>(Collections.singletonList("roles"))); // custom extension
+
+    private final Set<String> claims;
+
+    OIDCScope(Set<String> claims) {
+        this.claims = Collections.unmodifiableSet(new HashSet<>(claims));
+    }
+
+    public Set<String> getClaims() {
+        return claims;
+    }
+
+    public static Set<String> claimsForScopes(String scopeString) {
+        Set<String> result = new HashSet<>();
+        if (StringUtils.isEmpty(scopeString)) {
+            return result;
+        }
+
+        for (String s : scopeString.split("\\s+")) {
+            try {
+                OIDCScope scope = OIDCScope.valueOf(s.toUpperCase(Locale.US));
+                result.addAll(scope.getClaims());
+            } catch (IllegalArgumentException ignored) {
+                // ignore unknown scopes
+            }
+        }
+        return result;
+    }
+}
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
new file mode 100644
index 000000000..05675e491
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import com.nimbusds.jose.KeyLengthException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.service.knoxtoken.ClientCredentialsResource;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.AliasServiceException;
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.glassfish.jersey.process.internal.RequestScoped;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletException;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.DEFAULT_SCOPES;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
+
+@Path(RegistrationResource.RESOURCE_PATH)
+@RequestScoped //this is important because redirectUris/allowedScopes are part of the state of this class
+public class RegistrationResource extends ClientCredentialsResource {
+
+    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/client";
+    private List<String> redirectUris;
+    private List<String> allowedScopes;
+
+    @PostConstruct
+    @Override
+    public void init() throws ServletException, AliasServiceException, ServiceLifecycleException, KeyLengthException {
+        super.init();
+    }
+
+    @Override
+    @GET
+    public Response doGet() {
+        throw new UnsupportedOperationException("Not supported yet.");
+    }
+
+    @Override
+    @POST
+    public Response doPost() {
+        throw new UnsupportedOperationException("Not supported yet.");
+    }
+
+    @Path("/register")
+    @POST
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    public Response registerClient(@FormParam("redirect_uris") String redirectUris,
+                                   @FormParam("allowed_scopes") String allowedScopes) {
+        this.redirectUris = Arrays.asList(redirectUris.split(","));
+        final Response redirectUriVerificationResponse = verifyRedirectUris();
+        if (redirectUriVerificationResponse != null) {
+            return redirectUriVerificationResponse;
+        }
+
+        if (StringUtils.isBlank(allowedScopes)) {
+            this.allowedScopes = new ArrayList<>(DEFAULT_SCOPES);
+        } else {
+            this.allowedScopes = Arrays.asList(allowedScopes.split(","));
+            if (!this.allowedScopes.contains("openid")) {
+                return error("invalid_request", "allowed_scopes must include 'openid'");
+            }
+        }
+        return super.doPost();
+    }
+
+    private Response verifyRedirectUris() {
+        if (redirectUris == null || redirectUris.isEmpty()) {
+            return error("invalid_request", "redirect_uris must be provided");
+        }
+
+        for (String uriStr : redirectUris) {
+            URI uri;
+            try {
+                uri = new URI(uriStr);
+            } catch (URISyntaxException e) {
+                return error("invalid_request", "Invalid redirect URI: " + uriStr);
+            }
+
+            // Scheme check
+            if (!"https".equalsIgnoreCase(uri.getScheme()) && !"http".equalsIgnoreCase(uri.getScheme())) {
+                return error("invalid_request", "Redirect URI must use HTTPS or HTTP as scheme: " + uriStr);
+            }
+
+            // Host check (no wildcard allowed)
+            if (uri.getHost() == null || uri.getHost().contains("*")) {
+                return error("invalid_request", "Wildcard not allowed in host: " + uriStr);
+            }
+
+            // Path wildcard check
+            String path = uri.getPath();
+            if (path != null && path.contains("*") && !path.endsWith("*")) {
+                return error("invalid_request", "Wildcard '*' only allowed at end of path: " + uriStr);
+            }
+
+            // Query/fragment check
+            if ((uri.getQuery() != null && uri.getQuery().contains("*")) ||
+                    (uri.getFragment() != null && uri.getFragment().contains("*"))) {
+                return error("invalid_request", "Wildcard '*' not allowed in query or fragment: " + uriStr);
+            }
+        }
+        return null;
+    }
+
+    @Override
+    protected void addArbitraryTokenMetadata(TokenMetadata tokenMetadata) {
+        tokenMetadata.add("redirect_uris", getRedirectUris());
+        tokenMetadata.add("allowed_scopes", getAllowedScopes().replaceAll(",", " "));
+        super.addArbitraryTokenMetadata(tokenMetadata);
+    }
+
+    @Override
+    protected void decorateResponseMap(Map<String, Object> responseMap) {
+        responseMap.put("redirect_uris", getRedirectUris());
+        responseMap.put("allowed_scopes", getAllowedScopes());
+    }
+
+    private String getRedirectUris() {
+        return String.join(",", redirectUris);
+    }
+
+    private String getAllowedScopes() {
+        return String.join(",", allowedScopes);
+    }
+}
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
new file mode 100644
index 000000000..54e697eb9
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java
@@ -0,0 +1,452 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import com.nimbusds.jose.KeyLengthException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider;
+import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory;
+import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
+import org.apache.knox.gateway.services.security.AliasServiceException;
+import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
+import org.apache.knox.gateway.services.security.token.JWTokenAuthority;
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenMetadataType;
+import org.apache.knox.gateway.services.security.token.TokenServiceException;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
+import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.util.ServletRequestUtils;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.ParseException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.AUTH_CODE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE_METHOD;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_VERIFIER;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.FEDERATED_IDENTITY_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.OFFLINE_ACCESS_SCOPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.PKCE_METHOD_PLAIN;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.PKCE_METHOD_S256;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN_TTL;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN_TTL_DEFAULT;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
+
+@Path(TokenResource.RESOURCE_PATH)
+@Produces(MediaType.APPLICATION_JSON)
+public class TokenResource extends PasscodeTokenResourceBase {
+    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/token";
+    private UserParamsProvider userParamsProvider;
+
+    @Context
+    private HttpServletRequest request;
+
+    @Context
+    private ServletContext servletContext;
+
+    private FederatedIdentityService federatedIdentityService;
+    private long refreshTokenTTL;
+
+    @Override
+    public String getPrefix() {
+        return "knoxidf";
+    }
+
+    @PostConstruct
+    @Override
+    public void init() throws ServletException, AliasServiceException, ServiceLifecycleException, KeyLengthException {
+        super.init();
+        this.servletContext = wrapContextForDefaultParams(this.servletContext);
+        this.userParamsProvider = UserParamsProviderFactory.getUserParamsProvider(servletContext);
+        final GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+        federatedIdentityService = services.getService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE);
+        setRefreshTokenTTL();
+    }
+
+    private void setRefreshTokenTTL() {
+        final String configuredRefreshTokenTTL = servletContext.getInitParameter(REFRESH_TOKEN_TTL);
+        if (StringUtils.isNotBlank(configuredRefreshTokenTTL)) {
+            this.refreshTokenTTL = Long.parseLong(configuredRefreshTokenTTL);
+        } else {
+            refreshTokenTTL = REFRESH_TOKEN_TTL_DEFAULT;
+        }
+    }
+
+    @Override
+    @POST
+    public Response doPost() {
+        final String grantType = getRequestParam(GRANT_TYPE);
+        if (REFRESH_TOKEN.equals(grantType)) {
+            return handleRefreshToken();
+        } else if (AUTH_CODE.equals(grantType)) {
+            return handleAuthorizationCodeFlow();
+        }
+        return error("invalid_request", "invalid grant type: " + grantType);
+    }
+
+    @Override
+    protected UserContext buildUserContext(HttpServletRequest request) {
+        try {
+            final String code = getRequestParam(CODE);
+            final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(code);
+            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);
+        }
+    }
+
+    @Override
+    protected void addArbitraryTokenMetadata(TokenMetadata tokenMetadata) {
+        try {
+            super.addArbitraryTokenMetadata(tokenMetadata);
+            final String code = getRequestParam(CODE);
+            if (StringUtils.isNotBlank(code)) {
+                final TokenMetadata authCodeTokenMetadata = tokenStateService.getTokenMetadata(code);
+
+                //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);
+        }
+    }
+
+    @Override
+    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 = tokenStateService.getTokenMetadata(code);
+            } catch (UnknownTokenException e) {
+                //NOP
+            }
+        }
+
+        responseMap.map.put("id_token", generateIdToken(token, authCodeTokenMetadata));
+
+        final String refreshToken = generateRefreshToken(token);
+        if (StringUtils.isNotBlank(refreshToken)) {
+            responseMap.map.put(REFRESH_TOKEN, refreshToken);
+        }
+
+        return responseMap;
+    }
+
+    private Response handleRefreshToken() {
+        try {
+            final String refreshTokenParam = getRequestParam(REFRESH_TOKEN);
+            final String refreshTokenId = TokenUtils.getTokenId(refreshTokenParam);
+            final TokenMetadata refreshTokenMetadata = tokenStateService.getTokenMetadata(refreshTokenId);
+            validateRefreshTokenGrant(refreshTokenParam, refreshTokenId, refreshTokenMetadata);
+            // Valid refresh token -> issue new access token and new refresh token (rotation)
+            final String userName = refreshTokenMetadata.getUserName();
+            final String scope = refreshTokenMetadata.getMetadata(SCOPE);
+            final Map<String, Object> userParams = userParamsProvider.getParamsFor(userName, scope);
+            userParams.put(SCOPE, scope);
+
+            // Revoke old refresh token (rotation)
+            tokenStateService.revokeToken(refreshTokenId);
+
+            // Build new tokens
+            final UserContext userContext = new UserContext(userName, null, userParams);
+            final TokenResponseContext resp = getTokenResponse(userContext);
+            return resp.build();
+        } catch (ParseException e) {
+            return error("invalid_grant", "Malformed refresh_token");
+        } catch (UnknownTokenException e) {
+            return error("invalid_grant", "Unknown refresh_token");
+        } catch (RefreshTokenValidationError e) {
+            return error("Refresh token validation error", e.getMessage());
+        }
+
+    }
+
+    private void validateRefreshTokenGrant(String refreshTokenParam, String refreshTokenId, TokenMetadata refreshTokenMetadata) throws UnknownTokenException, RefreshTokenValidationError {
+        final String clientId = getRequestParam(CLIENT_ID);
+
+        if (StringUtils.isBlank(refreshTokenParam)) {
+            throw new RefreshTokenValidationError("Invalid request: Missing refresh_token");
+        }
+
+        if (StringUtils.isBlank(clientId)) {
+            throw new RefreshTokenValidationError("Invalid request: Missing client_id");
+        }
+
+        if (refreshTokenMetadata == null || !TokenMetadataType.REFRESH_TOKEN.name().equals(refreshTokenMetadata.getType())) {
+            throw new RefreshTokenValidationError("Invalid grant: invalid refresh_token");
+        }
+
+        if (tokenStateService.getTokenExpiration(refreshTokenId) <= System.currentTimeMillis()) {
+            throw new RefreshTokenValidationError("Invalid grant: Refresh token expired");
+        }
+
+        final String associatedClientId = refreshTokenMetadata.getMetadata(CLIENT_ID);
+        if (!clientId.equals(associatedClientId)) {
+            throw new RefreshTokenValidationError("Invalid grant: client_id mismatch");
+        }
+    }
+
+    private Response handleAuthorizationCodeFlow() {
+        final String code = getRequestParam(CODE);
+        final String redirectUri = getRequestParam(REDIRECT_URI);
+
+        try {
+            validateAuthCode(code, redirectUri);
+            return getAuthenticationToken();
+        } catch (AuthTokenValidationError e) {
+            return error("Auth code validation error", e.getMessage());
+        } finally {
+            try {
+                tokenStateService.revokeToken(code);
+            } catch (UnknownTokenException e) {
+                //NOP: this should have been handled by the above UnknownTokenException already
+            }
+        }
+    }
+
+    private void validateAuthCode(String code, String redirectUri) throws AuthTokenValidationError {
+        try {
+            if (code == null || code.isEmpty()) {
+                throw new AuthTokenValidationError("Invalid request: missing code");
+            }
+
+            if (redirectUri == null || redirectUri.isEmpty()) {
+                throw new AuthTokenValidationError("Invalid request: missing redirect_uri");
+            }
+
+            final TokenMetadata authCodeTokenMetadata = tokenStateService.getTokenMetadata(code);
+            final String associateRedirectUri = authCodeTokenMetadata.getMetadata(REDIRECT_URI);
+            if (!authCodeTokenMetadata.isAuthCode()) {
+                throw new AuthTokenValidationError("Invalid auth_code: not an auth code token");
+            } else if (tokenStateService.getTokenExpiration(code) <= System.currentTimeMillis()) {
+                throw new AuthTokenValidationError("Invalid auth_code: expired");
+            } else if (!associateRedirectUri.equals(redirectUri)) {
+                throw new AuthTokenValidationError("Invalid redirect_uri: " + redirectUri);
+            } else {
+                final String associatedClientId = authCodeTokenMetadata.getMetadata(CLIENT_ID);
+                final String clientId = getRequestParam(CLIENT_ID);
+                if (!associatedClientId.equals(clientId)) {
+                    throw new AuthTokenValidationError("Invalid client_id: " + clientId);
+                }
+            }
+
+            // PKCE validation
+            final String codeChallenge = authCodeTokenMetadata.getMetadata(CODE_CHALLENGE);
+            if (StringUtils.isNotBlank(codeChallenge)) {
+                final String codeChallengeMethod = authCodeTokenMetadata.getMetadata(CODE_CHALLENGE_METHOD);
+                final String codeVerifier = getRequestParam(CODE_VERIFIER);
+                if (StringUtils.isBlank(codeVerifier)) {
+                    throw new AuthTokenValidationError("Missing code_verifier");
+                }
+                if (!validatePKCE(codeVerifier, codeChallenge, codeChallengeMethod)) {
+                    throw new AuthTokenValidationError("Invalid code_verifier");
+                }
+            }
+        } catch (UnknownTokenException e) {
+            throw new AuthTokenValidationError("Unknown auth_code");
+        }
+    }
+
+    private boolean validatePKCE(String codeVerifier, String codeChallenge, String method) {
+        if (PKCE_METHOD_PLAIN.equals(method)) {
+            return codeVerifier.equals(codeChallenge);
+        } else if (PKCE_METHOD_S256.equals(method)) {
+            try {
+                return generateS256Challenge(codeVerifier).equals(codeChallenge);
+            } catch (NoSuchAlgorithmException e) {
+                return false;
+            }
+        }
+        return false;
+    }
+
+    private String generateS256Challenge(String codeVerifier) throws NoSuchAlgorithmException {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+    }
+
+    private String generateIdToken(JWT accessToken, TokenMetadata authCodeTokenMetadata) throws TokenServiceException {
+        final boolean hasFederatedIdToken = authCodeTokenMetadata != null && StringUtils.isNotBlank(authCodeTokenMetadata.getMetadata(FEDERATED_IDENTITY_ID));
+
+        if (hasFederatedIdToken) {
+            return generateFederatedIdToken(accessToken, authCodeTokenMetadata);
+        } else {
+            return generateLocalIdToken(accessToken, authCodeTokenMetadata);
+        }
+    }
+
+    private String generateFederatedIdToken(JWT accessToken, TokenMetadata tokenMetadata) throws TokenServiceException {
+        final String fedIdentityId = tokenMetadata.getMetadata(FEDERATED_IDENTITY_ID);
+        final FederatedIdentity federatedIdentity = federatedIdentityService
+                .findById(fedIdentityId)
+                .orElseThrow(() -> new TokenServiceException("Federated identity not found"));
+
+        final JWTokenAttributesBuilder builder = new JWTokenAttributesBuilder();
+        builder.setAlgorithm(accessToken.getSignatureAlgorithm().getName())
+                .setUserName(federatedIdentity.getUserId())
+                .setIssueTime(System.currentTimeMillis())
+                .setExpires(Long.parseLong(accessToken.getExpires()))
+                .setIssuer(accessToken.getIssuer())
+                .setAudiences(tokenMetadata.getMetadata(CLIENT_ID));
+
+        final Map<String, Object> claims = new HashMap<>(federatedIdentity.getAttributes());
+        claims.keySet().retainAll(AuthorizeResource.ALLOWED_CLAIMS);
+        String nonce = tokenMetadata.getMetadata("nonce");
+        if (StringUtils.isNotBlank(nonce)) {
+            claims.put("nonce", nonce);
+        }
+
+        // Optional: indicate source for auditing/logging
+        claims.put("federated_idp", federatedIdentity.getProvider());
+        claims.put("federated_sub", federatedIdentity.getExternalSubject());
+        claims.put("federated_iss", federatedIdentity.getExternalIssuer());
+
+        builder.setCustomAttributes(claims);
+
+        return issueToken(builder).toString();
+    }
+
+    private String generateLocalIdToken(JWT accessToken, TokenMetadata authCodeTokenMetadata) throws TokenServiceException {
+        final JWTokenAttributesBuilder idTokenAttributesBuilder = new JWTokenAttributesBuilder();
+        idTokenAttributesBuilder
+                .setAlgorithm(accessToken.getSignatureAlgorithm().getName())
+                .setUserName(accessToken.getSubject())
+                .setIssueTime(System.currentTimeMillis())
+                .setExpires(Long.parseLong(accessToken.getExpires()))
+                .setIssuer(accessToken.getIssuer());
+
+        if (authCodeTokenMetadata != null) {
+            final String associatedClientId = authCodeTokenMetadata.getMetadata("client_id");
+            idTokenAttributesBuilder.setAudiences(associatedClientId);
+            final String nonce = authCodeTokenMetadata.getMetadata("nonce");
+            if (StringUtils.isNotBlank(nonce)) {
+                idTokenAttributesBuilder.setCustomAttributes(Map.of("nonce", nonce));
+            }
+        } else {
+            // If there is no auth code (e.g. refresh token grant), we use the client_id from the request
+            idTokenAttributesBuilder.setAudiences(getRequestParam(CLIENT_ID));
+        }
+
+        return issueToken(idTokenAttributesBuilder).toString();
+    }
+
+    private String generateRefreshToken(JWT accessToken) throws TokenServiceException {
+        final String scope = (String) accessToken.getJWTClaimsSet().getClaim(SCOPE);
+        if (StringUtils.isNotBlank(scope) && scope.contains(OFFLINE_ACCESS_SCOPE)) {
+            return issueRefreshToken(accessToken, scope);
+        } else {
+            return null;
+        }
+    }
+
+    private String issueRefreshToken(JWT accessToken, String scope) throws TokenServiceException {
+        final JWTokenAttributesBuilder refreshTokenAttributesBuilder = new JWTokenAttributesBuilder();
+
+        final long issueTime = System.currentTimeMillis();
+        final long expires = issueTime + refreshTokenTTL;
+        final String clientId = getRequestParam(CLIENT_ID);
+
+        refreshTokenAttributesBuilder.setIssuer(accessToken.getIssuer())
+                .setUserName(accessToken.getSubject())
+                .setAlgorithm(accessToken.getSignatureAlgorithm().getName())
+                .setAudiences(clientId)
+                .setIssueTime(issueTime)
+                .setExpires(expires)
+                .setManaged(tokenStateService != null)
+                .setType(TokenMetadataType.REFRESH_TOKEN.name());
+
+        final JWT refreshToken = issueToken(refreshTokenAttributesBuilder);
+
+        if (tokenStateService != null) {
+            final String tokenId = TokenUtils.getTokenId(refreshToken);
+            tokenStateService.addToken(tokenId, issueTime, expires, tokenStateService.getDefaultMaxLifetimeDuration());
+            final TokenMetadata metadata = new TokenMetadata(refreshToken.getSubject());
+            metadata.setType(TokenMetadataType.REFRESH_TOKEN);
+            metadata.add("client_id", clientId);
+            metadata.add("scope", scope);
+            tokenStateService.addMetadata(tokenId, metadata);
+        }
+
+        return refreshToken.toString();
+    }
+
+    private JWT issueToken(final JWTokenAttributesBuilder builder) throws TokenServiceException {
+        final JWTokenAuthority ts = getGatewayServices().getService(ServiceType.TOKEN_SERVICE);
+        return ts.issueToken(builder.build());
+    }
+
+    private String getRequestParam(String paramName) {
+        String requestParamValue = request.getParameter(paramName);
+        if (requestParamValue == null) {
+            requestParamValue = ServletRequestUtils.unwrapHttpServletRequest(request).getParameter(paramName);
+        }
+        return requestParamValue;
+    }
+
+    private static class AuthTokenValidationError extends Exception {
+        AuthTokenValidationError(String message) {
+            super(message);
+        }
+    }
+
+    private static class RefreshTokenValidationError extends Exception {
+        RefreshTokenValidationError(String message) {
+            super(message);
+        }
+    }
+}
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
new file mode 100644
index 000000000..c837605ae
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider;
+import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity;
+import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenServiceException;
+import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.apache.knox.gateway.util.JsonUtils;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE_ATTRIBUTE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_ID_ATTRIBUTE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
+
+
+@Path(UserInfoResource.RESOURCE_PATH)
+@Produces(MediaType.APPLICATION_JSON)
+public class UserInfoResource {
+
+    static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/userinfo";
+    private UserParamsProvider userParamsProvider;
+
+    @Context
+    private ServletContext servletContext;
+
+    @Context
+    private HttpServletRequest request;
+
+    private FederatedIdentityService federatedIdentityService;
+
+    @PostConstruct
+    public void init() {
+        this.userParamsProvider = UserParamsProviderFactory.getUserParamsProvider(servletContext);
+        final GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+        federatedIdentityService = services.getService(ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE);
+    }
+
+    public Response doGet() {
+        try {
+            return getUserInfo();
+        } catch (UnknownTokenException | TokenServiceException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public Response doPost() {
+        throw new UnsupportedOperationException();
+    }
+
+    @GET
+    @Produces(MediaType.APPLICATION_JSON)
+    public Response getUserInfo() throws UnknownTokenException, TokenServiceException {
+        final String tokenId = request.getAttribute(TOKEN_ID_ATTRIBUTE) == null ? null : request.getAttribute(TOKEN_ID_ATTRIBUTE).toString();
+        if (tokenId == null) {
+            return error("invalid_request", "Cannot find tokenId");
+        }
+
+        final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString();
+        final TokenMetadata tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId);
+        final Map<String, Object> userInfo = new HashMap<>();
+
+        // Check if this token has a federated identity
+        final String federatedIdentityId = tokenMetadata.getMetadata("federated_identity_id");
+
+        if (StringUtils.isNotBlank(federatedIdentityId)) {
+            // Federated user
+            final FederatedIdentity federatedIdentity = federatedIdentityService
+                    .findById(federatedIdentityId)
+                    .orElseThrow(() -> new TokenServiceException("Federated identity not found"));
+
+            // Include only allowed claims
+            Map<String, Object> claims = federatedIdentity.getAttributes().entrySet().stream()
+                    .filter(e -> AuthorizeResource.ALLOWED_CLAIMS.contains(e.getKey()))
+                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+            // Mandatory claims for OIDC
+            claims.put("sub", federatedIdentity.getUserId()); // internal Knox subject
+            claims.put("idp", federatedIdentity.getProvider());
+
+            // Optional: federated info for auditing
+            claims.put("federated_sub", federatedIdentity.getExternalSubject());
+            claims.put("federated_iss", federatedIdentity.getExternalIssuer());
+
+            // Add nonce if available
+            String nonce = tokenMetadata.getMetadata("nonce");
+            if (StringUtils.isNotBlank(nonce)) {
+                claims.put("nonce", nonce);
+            }
+
+            userInfo.putAll(claims);
+        } else {
+            // Local Knox user
+            userInfo.putAll(userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope));
+        }
+
+        return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build();
+    }
+
+    private TokenStateService getReadonlyTokenStateService() {
+        GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+        return services.getService(ServiceType.TOKEN_STATE_SERVICE);
+    }
+
+}
+
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.java
similarity index 54%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.java
index 17e82e0af..cfbb4647c 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.java
@@ -5,19 +5,38 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.service.knoxidf.deploy;
 
-public enum TokenMetadataType {
+import org.apache.knox.gateway.jersey.JerseyServiceDeploymentContributorBase;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+public class KnoxIDFServiceDeploymentContributor extends JerseyServiceDeploymentContributorBase {
 
+    @Override
+    public String getRole() {
+        return "KNOXIDF";
+    }
+
+    @Override
+    public String getName() {
+        return "KnoxIdentityFederation";
+    }
+
+    @Override
+    protected String[] getPackages() {
+        return new String[] { "org.apache.knox.gateway.service.knoxidf" };
+    }
+
+    @Override
+    protected String[] getPatterns() {
+        return new String[] { "knoxidf/api/**?**" };
+    }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java
similarity index 71%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java
index 17e82e0af..f39d3eb77 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java
@@ -5,19 +5,24 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.service.knoxidf.userparams;
 
-public enum TokenMetadataType {
+import java.util.HashMap;
+import java.util.Map;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+public class EmptyUserParamsProvider implements UserParamsProvider {
 
+    @Override
+    public Map<String, Object> getParamsFor(String subjectName, String scope) {
+        return new HashMap<>();
+    }
 }
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java
new file mode 100644
index 000000000..a17248451
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java
@@ -0,0 +1,201 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf.userparams;
+
+import org.apache.knox.gateway.service.knoxidf.OIDCScope;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.AliasServiceException;
+
+import javax.naming.Context;
+import javax.naming.NamingEnumeration;
+import javax.naming.directory.Attribute;
+import javax.naming.directory.Attributes;
+import javax.naming.directory.SearchControls;
+import javax.naming.directory.SearchResult;
+import javax.naming.ldap.InitialLdapContext;
+import javax.naming.ldap.LdapContext;
+import javax.servlet.ServletContext;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+public class LdapUserParamsProvider implements UserParamsProvider {
+    private static final String PREFIX = "user.params.provider.ldap.";
+    static final String LDAP_URL = PREFIX + "url";
+    private static final String LDAP_BASE_DN = PREFIX + "baseDn";
+    private static final String LDAP_USER_DN_TEMPLATE = PREFIX + "userDnTemplate";
+    private static final String LDAP_SYSTEM_USER = PREFIX + "systemUser";
+    private static final String LDAP_SYSTEM_PASSWORD_ALIAS = PREFIX + "systemPasswordAlias";
+
+    // === Defaults point to Knox's demo LDAP ===
+    private static final String DEFAULT_BASE_DN = "dc=hadoop,dc=apache,dc=org";
+    private static final String DEFAULT_USER_DN_TEMPLATE = "uid=%s,ou=people," + DEFAULT_BASE_DN;
+    private static final String DEFAULT_SYSTEM_USER = "uid=admin,ou=people," + DEFAULT_BASE_DN;
+    private static final String DEFAULT_SYSTEM_PASSWORD = "admin-password";
+
+    private static final String[] ATTRIBUTES = {"cn", "sn", "givenName", "mail"};
+
+    private final String ldapUrl;
+    private final String ldapBaseDn;
+    private final String ldapUserDnTemplate;
+    private final String ldapSystemUser;
+    private final String ldapSystemPassword;
+
+    LdapUserParamsProvider(ServletContext servletContext) {
+        this.ldapUrl = servletContext.getInitParameter(LDAP_URL);
+        this.ldapBaseDn = getInitParamOrDefault(servletContext, LDAP_BASE_DN, DEFAULT_BASE_DN);
+        this.ldapUserDnTemplate = getInitParamOrDefault(servletContext, LDAP_USER_DN_TEMPLATE, DEFAULT_USER_DN_TEMPLATE);
+        this.ldapSystemUser = getInitParamOrDefault(servletContext, LDAP_SYSTEM_USER, DEFAULT_SYSTEM_USER);
+        this.ldapSystemPassword = getSystemPassword(servletContext);
+    }
+
+    private String getInitParamOrDefault(ServletContext servletContext, String key, String defaultValue) {
+        final String value = servletContext.getInitParameter(key);
+        return value == null ? defaultValue : value;
+    }
+
+    private String getSystemPassword(ServletContext servletContext) {
+        final GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+        final AliasService aliasService = services.getService(ServiceType.ALIAS_SERVICE);
+        try {
+            final char[] systemPassword = aliasService.getPasswordFromAliasForGateway(LDAP_SYSTEM_PASSWORD_ALIAS);
+            return systemPassword == null ? DEFAULT_SYSTEM_PASSWORD : new String(systemPassword);
+        } catch (AliasServiceException e) {
+            return DEFAULT_SYSTEM_PASSWORD;
+        }
+    }
+
+    @Override
+    public Map<String, Object> getParamsFor(String subjectName, String scope) {
+        Map<String, Object> userParams = new HashMap<>();
+        if ("anonymous".equalsIgnoreCase(subjectName)) {
+            return userParams;
+        }
+
+        Set<String> requestedClaims = OIDCScope.claimsForScopes(scope);
+
+        LdapContext ctx = null;
+        try {
+            ctx = createSystemContext();
+
+            String userDn = String.format(Locale.US, ldapUserDnTemplate, subjectName);
+
+            SearchControls controls = new SearchControls();
+            controls.setSearchScope(SearchControls.OBJECT_SCOPE);
+            controls.setReturningAttributes(ATTRIBUTES);
+
+            NamingEnumeration<SearchResult> results = ctx.search(userDn, "(objectClass=*)", controls);
+            if (results.hasMore()) {
+                SearchResult sr = results.next();
+                Attributes attrs = sr.getAttributes();
+
+                // --- OIDC standard claims ---
+                if (requestedClaims.contains("sub")) {
+                    userParams.put("sub", subjectName);
+                }
+                if (requestedClaims.contains("name")) {
+                    userParams.put("name", getAttr(attrs, "cn"));
+                }
+                if (requestedClaims.contains("family_name")) {
+                    userParams.put("family_name", getAttr(attrs, "sn"));
+                }
+                if (requestedClaims.contains("given_name")) {
+                    userParams.put("given_name", getAttr(attrs, "givenName"));
+                }
+                if (requestedClaims.contains("email")) {
+                    userParams.put("email", getAttr(attrs, "mail"));
+                }
+                if (requestedClaims.contains("email_verified")) {
+                    userParams.put("email_verified", Boolean.TRUE);
+                }
+
+                // --- Custom: roles ---
+                if (requestedClaims.contains("roles")) {
+                    List<String> roles = fetchRoles(ctx, userDn);
+                    userParams.put("roles", roles);
+                }
+            }
+
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to fetch user parameters for " + subjectName, e);
+        } finally {
+            closeContext(ctx);
+        }
+
+        return userParams;
+    }
+
+    private List<String> fetchRoles(LdapContext ctx, String userDn) throws Exception {
+        List<String> roles = new ArrayList<>();
+
+        SearchControls groupControls = new SearchControls();
+        groupControls.setSearchScope(SearchControls.ONELEVEL_SCOPE);
+        groupControls.setReturningAttributes(new String[]{"cn", "member"});
+
+        String groupsBase = "ou=groups," + ldapBaseDn;
+        NamingEnumeration<SearchResult> groupResults =
+                ctx.search(groupsBase, "(objectClass=groupOfNames)", groupControls);
+
+        while (groupResults.hasMore()) {
+            SearchResult group = groupResults.next();
+            Attributes groupAttrs = group.getAttributes();
+            Attribute members = groupAttrs.get("member");
+            if (members != null) {
+                NamingEnumeration<?> e = members.getAll();
+                while (e.hasMore()) {
+                    String memberDn = (String) e.next();
+                    if (memberDn.equalsIgnoreCase(userDn)) {
+                        roles.add(getAttr(groupAttrs, "cn"));
+                        break;
+                    }
+                }
+            }
+        }
+        return roles;
+    }
+
+    private LdapContext createSystemContext() throws Exception {
+        Hashtable<String, Object> env = new Hashtable<>();
+        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
+        env.put(Context.PROVIDER_URL, ldapUrl);
+        env.put(Context.SECURITY_AUTHENTICATION, "simple");
+        env.put(Context.SECURITY_PRINCIPAL, ldapSystemUser);
+        env.put(Context.SECURITY_CREDENTIALS, ldapSystemPassword);
+        return new InitialLdapContext(env, null);
+    }
+
+    private String getAttr(Attributes attrs, String attrName) throws Exception {
+        Attribute attr = attrs.get(attrName);
+        return attr != null ? (String) attr.get() : null;
+    }
+
+    private void closeContext(LdapContext ctx) {
+        if (ctx != null) {
+            try {
+                ctx.close();
+            } catch (Exception ignored) {
+            }
+        }
+    }
+}
+
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java
similarity index 65%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java
index 17e82e0af..d052ef040 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java
@@ -5,19 +5,26 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.service.knoxidf.userparams;
 
-public enum TokenMetadataType {
+import java.util.Map;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+public interface UserParamsProvider {
 
+    /**
+     * Fetches OIDC parameters for the given subject name.
+     *
+     * @param subjectName The user login/ID (e.g., "sam").
+     * @return a map of OIDC parameters (e.g., email, name, roles)
+     */
+    Map<String, Object> getParamsFor(String subjectName, String scope);
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java
similarity index 63%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java
index 17e82e0af..7cdf31984 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java
@@ -5,19 +5,22 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.service.knoxidf.userparams;
 
-public enum TokenMetadataType {
-
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+import javax.servlet.ServletContext;
 
+public class UserParamsProviderFactory {
+    public static UserParamsProvider getUserParamsProvider(ServletContext servletContext) {
+        final String ldapUrl = servletContext.getInitParameter(LdapUserParamsProvider.LDAP_URL);
+        return ldapUrl == null ? new EmptyUserParamsProvider() : new LdapUserParamsProvider(servletContext);
+    }
 }
diff --git a/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor
new file mode 100644
index 000000000..49fc687f1
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor
@@ -0,0 +1,18 @@
+##########################################################################
+# 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.
+##########################################################################
+org.apache.knox.gateway.service.knoxidf.deploy.KnoxIDFServiceDeploymentContributor
diff --git a/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java b/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java
index cf73bc987..eea58f1dc 100644
--- a/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java
+++ b/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/WebSSOResource.java
@@ -17,35 +17,6 @@
  */
 package org.apache.knox.gateway.service.knoxsso;
 
-import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
-import static javax.ws.rs.core.MediaType.APPLICATION_XML;
-import static org.apache.knox.gateway.services.GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE;
-
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.security.Principal;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-
-import javax.annotation.PostConstruct;
-import javax.servlet.ServletContext;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
-import javax.ws.rs.GET;
-import javax.ws.rs.POST;
-import javax.ws.rs.Path;
-import javax.ws.rs.Produces;
-import javax.ws.rs.WebApplicationException;
-import javax.ws.rs.core.Context;
-import javax.ws.rs.core.Response;
-
 import com.nimbusds.jose.JOSEObjectType;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.knox.gateway.audit.log4j.audit.Log4jAuditor;
@@ -70,6 +41,39 @@ import org.apache.knox.gateway.util.SetCookieHeader;
 import org.apache.knox.gateway.util.Tokens;
 import org.apache.knox.gateway.util.Urls;
 import org.apache.knox.gateway.util.WhitelistUtils;
+import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration;
+import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.ServletContext;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.Principal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.Set;
+
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
+import static javax.ws.rs.core.MediaType.APPLICATION_XML;
+import static org.apache.knox.gateway.services.GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE;
 
 @Path( WebSSOResource.RESOURCE_PATH )
 public class WebSSOResource {
@@ -108,13 +112,14 @@ public class WebSSOResource {
   private String tokenType;
   private String whitelist;
   private String domainSuffix;
-  private List<String> targetAudiences = new ArrayList<>();
+  private final List<String> targetAudiences = new ArrayList<>();
   private boolean enableSession;
   private String signatureAlgorithm;
   private List<String> ssoExpectedparams = new ArrayList<>();
   private String clusterName;
   private String tokenIssuer;
   private TokenStateService tokenStateService;
+  private final FederatedOpConfigurationStore federatedOpConfigurationStore = FederatedOpConfigurationStore.getInstance(120000L);
 
   private String sameSiteValue;
 
@@ -226,6 +231,25 @@ public class WebSSOResource {
     tokenType = StringUtils.isBlank(configuredTokenType) ? JOSEObjectType.JWT.getType() : configuredTokenType;
   }
 
+  @Path("/federated/op")
+  @GET
+  public Response federatedOpLogin() {
+    final String loginSessionId = request.getParameter("fedOpSid");
+    final String opName = request.getParameter("fedOpName");
+    final Optional<FederatedOpConfiguration> federatedOpConfig = federatedOpConfigurationStore.get(loginSessionId).stream()
+            .filter(federatedOpConfiguration -> federatedOpConfiguration.getName().equals(opName))
+            .findFirst();
+    if (federatedOpConfig.isPresent()) {
+      final FederatedOpConfiguration federatedOpConfiguration = federatedOpConfig.get();
+      //keep only the selected federated OP in the cache -> we can easily get it in the AuthorizeResource.authCallback endpoint
+      federatedOpConfigurationStore.put(loginSessionId, Set.of(federatedOpConfiguration));
+      final String federatedOpAuthRedirect = KnoxIDFUtils.buildFederatedOpAuthRedirect(federatedOpConfiguration, loginSessionId);
+      return Response.seeOther(java.net.URI.create(federatedOpAuthRedirect)).build();
+    } else {
+      return KnoxIDFUtils.error("invalid_request", "Cannot load federated op config associated with login session");
+    }
+  }
+
   @GET
   @Produces({APPLICATION_JSON, APPLICATION_XML})
   public Response doGet() {
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java
index d23c693ee..4c17c02db 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java
@@ -33,6 +33,7 @@ import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.Response;
 import java.util.HashMap;
+import java.util.Map;
 
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 import static javax.ws.rs.core.MediaType.APPLICATION_XML;
@@ -98,6 +99,7 @@ public class ClientCredentialsResource extends PasscodeTokenResourceBase {
             map.put(CLIENT_ID, tokenId);
             map.put(CLIENT_SECRET, passcode);
             addExpiryIfNotNever(map);
+            decorateResponseMap(map);
             String jsonResponse = JsonUtils.renderAsJsonString(map);
             return resp.responseBuilder.entity(jsonResponse).build();
         }
@@ -108,4 +110,8 @@ public class ClientCredentialsResource extends PasscodeTokenResourceBase {
             return resp.responseBuilder.build();
         }
     }
+
+    protected void decorateResponseMap(Map<String, Object> responseMap) {
+        //NOP
+    }
 }
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
index 36fe65374..ca59dc1eb 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
@@ -116,7 +116,7 @@ public class TokenResource {
   protected static final String TOKEN_TYPE = "token_type";
   protected static final String ACCESS_TOKEN = "access_token";
   protected static final String TOKEN_ID = "token_id";
-  static final String PASSCODE = "passcode";
+  public static final String PASSCODE = "passcode";
   protected static final String MANAGED_TOKEN = "managed";
   private static final String TARGET_URL = "target_url";
   private static final String ENDPOINT_PUBLIC_CERT = "endpoint_public_cert";
@@ -146,6 +146,7 @@ public class TokenResource {
   private static final String LIFESPAN_INPUT_ENABLED_TEXT = "lifespanInputEnabled";
   static final String KNOX_TOKEN_USER_LIMIT_PER_USER = TOKEN_PARAM_PREFIX + "limit.per.user";
   static final String KNOX_TOKEN_USER_LIMIT_EXCEEDED_ACTION = TOKEN_PARAM_PREFIX + "user.limit.exceeded.action";
+  private static final String KNOX_TOKEN_HARDCODED_CLAIM_MAPPINGS = TOKEN_PARAM_PREFIX + "hardcoded.claim.mappings";
   private static final String METADATA_QUERY_PARAM_PREFIX = "md_";
   private static final String TOKEN_ENABLE_DELEGATED_AUTH = TOKEN_PARAM_PREFIX + "enable.delegated.auth";
   private static final long TOKEN_TTL_DEFAULT = 30000L;
@@ -188,6 +189,7 @@ public class TokenResource {
   private Optional<Long> maxTokenLifetime = Optional.empty();
 
   private int tokenLimitPerUser;
+  private Map<String, Object> hardCodedClaimMappings;
   private boolean includeGroupsInTokenAllowed;
   private String tokenIssuer;
   private boolean enableDelegatedAuth;
@@ -365,9 +367,37 @@ public class TokenResource {
               .filter(s -> !s.isEmpty())
               .collect(Collectors.toSet());
     }
+
+    parseHardcodedClaimMappings(context.getInitParameter(KNOX_TOKEN_HARDCODED_CLAIM_MAPPINGS));
     setTokenStateServiceStatusMap();
   }
 
+  private void parseHardcodedClaimMappings(String raw) {
+    hardCodedClaimMappings = new HashMap<>();
+
+    if (raw != null && !raw.isBlank()) {
+      Arrays.stream(raw.split(";"))
+              .map(String::trim)
+              .filter(s -> !s.isEmpty())
+              .map(entry -> entry.split("=", 2))
+              .filter(kv -> kv.length == 2)
+              .forEach(kv -> {
+                String key = kv[0].trim();
+                String value = kv[1].trim();
+
+                Object mappedValue =
+                        value.contains(",")
+                                ? Arrays.stream(value.split(","))
+                                .map(String::trim)
+                                .filter(v -> !v.isEmpty())
+                                .toList()
+                                : value;
+
+                hardCodedClaimMappings.put(key, mappedValue);
+              });
+    }
+  }
+
   private String getTokenTTLAsText() {
     if (tokenTTL == -1) {
       return "Unlimited lifetime";
@@ -701,7 +731,7 @@ public class TokenResource {
     } else {
       try {
         final String revoker = SubjectUtils.getCurrentEffectivePrincipalName();
-        final String tokenId = getTokenId(token);
+        final String tokenId = TokenUtils.getTokenId(token);
         if (isKnoxSsoCookie(tokenId)) {
           errorStatus = Response.Status.FORBIDDEN;
           error = "SSO cookie (" + Tokens.getTokenIDDisplayText(tokenId) + ") cannot not be revoked.";
@@ -753,22 +783,6 @@ public class TokenResource {
     return StringUtils.isNotBlank(revoker) && (revoker.equals(tokenUserName) || revoker.equals(tokenCreatedBy));
   }
 
-  /*
-   * If the supplied 'token' conforms the UUID string representation, we consider
-   * that as the token ID; otherwise we expect that 'token' is the entire JWT and
-   * we get the token ID from it
-   */
-  private String getTokenId(String token) throws ParseException {
-    try {
-      UUID.fromString(token);
-      return token;
-    } catch (IllegalArgumentException e) {
-      //NOP: the supplied token is not a UUID, we expect the entire JWT
-    }
-    final JWTToken jwt = new JWTToken(token);
-    return TokenUtils.getTokenId(jwt);
-  }
-
   @PUT
   @Path(ENABLE_PATH)
   @Produces({APPLICATION_JSON})
@@ -883,16 +897,17 @@ public class TokenResource {
 
   protected TokenResponseContext getTokenResponse(UserContext context) {
     TokenResponseContext response = null;
+    long issueTime = System.currentTimeMillis();
     long expires = getExpiry();
     setupPublicCertPEM();
     String jku = getJku();
     try
     {
-      JWT token = getJWT(context.userName, expires, jku);
+      JWT token = getJWT(context, issueTime, expires, jku);
       if (token != null) {
         ResponseMap result = buildResponseMap(token, expires);
         String jsonResponse = JsonUtils.renderAsJsonString(result.map);
-        persistTokenDetails(result, expires, context.userName, context.createdBy);
+        persistTokenDetails(result, issueTime, expires, context.userName, context.createdBy);
 
         response = new TokenResponseContext(result, jsonResponse, Response.ok());
       } else {
@@ -979,10 +994,16 @@ public class TokenResource {
   protected static class UserContext {
     public final String userName;
     public final String createdBy;
+    private final Map<String, Object> userParams;
 
     public UserContext(String userName, String createdBy) {
+      this(userName, createdBy, Collections.emptyMap());
+    }
+
+    public UserContext(String userName, String createdBy, Map<String, Object> userParams) {
       this.userName = userName;
       this.createdBy = createdBy;
+      this.userParams = userParams;
     }
   }
 
@@ -1054,13 +1075,10 @@ public class TokenResource {
     return response;
   }
 
-  protected void persistTokenDetails(ResponseMap result, long expires, String userName, String createdBy) {
+  protected void persistTokenDetails(ResponseMap result, long issueTime, long expires, String userName, String createdBy) {
     // Optional token store service persistence
     if (tokenStateService != null) {
-      final long issueTime = System.currentTimeMillis();
-      tokenStateService.addToken(result.tokenId,
-                                 issueTime,
-              expires,
+      tokenStateService.addToken(result.tokenId, issueTime, expires,
                                  maxTokenLifetime.orElse(tokenStateService.getDefaultMaxLifetimeDuration()));
       final String comment = request.getParameter(COMMENT);
       final TokenMetadata tokenMetadata = new TokenMetadata(userName, StringUtils.isBlank(comment) ? null : comment);
@@ -1074,7 +1092,7 @@ public class TokenResource {
     }
   }
 
-  protected ResponseMap buildResponseMap(JWT token, long expires) {
+  protected ResponseMap buildResponseMap(JWT token, long expires) throws TokenServiceException {
     String accessToken = token.toString();
     String tokenId = TokenUtils.getTokenId(token);
     final boolean managedToken = tokenStateService != null;
@@ -1118,7 +1136,7 @@ public class TokenResource {
     }
   }
 
-  protected JWT getJWT(String userName, long expires, String jku) throws TokenServiceException {
+  private JWT getJWT(UserContext userContext, long issueTime, long expires, String jku) throws TokenServiceException {
     JWTokenAttributes jwtAttributes;
     JWT token;
     JWTokenAuthority ts = getGatewayServices().getService(ServiceType.TOKEN_SERVICE);
@@ -1126,8 +1144,9 @@ public class TokenResource {
     final JWTokenAttributesBuilder jwtAttributesBuilder = new JWTokenAttributesBuilder();
     jwtAttributesBuilder
         .setIssuer(tokenIssuer)
-        .setUserName(userName)
+        .setUserName(userContext.userName)
         .setAlgorithm(signatureAlgorithm)
+        .setIssueTime(issueTime)
         .setExpires(expires)
         .setManaged(managedToken)
         .setJku(jku)
@@ -1150,6 +1169,14 @@ public class TokenResource {
       handleDelegatedAuthentication(subject, jwtAttributesBuilder);
     }
 
+    if (userContext.userParams != null) {
+      hardCodedClaimMappings.putAll(userContext.userParams);
+    }
+
+    if (!hardCodedClaimMappings.isEmpty()) {
+      jwtAttributesBuilder.setCustomAttributes(hardCodedClaimMappings);
+    }
+
     jwtAttributes = jwtAttributesBuilder.build();
     token = ts.issueToken(jwtAttributes);
     return token;
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java
index 1537f698b..2f28b9629 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java
@@ -27,4 +27,6 @@ public interface CommonTokenConstants {
 
     String CLIENT_SECRET = "client_secret";
 
+    String AUTH_CODE = "authorization_code";
+
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java
index 5caeac0e6..21794bc21 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/ServiceType.java
@@ -40,7 +40,8 @@ public enum ServiceType {
   REMOTE_CONFIGURATION_MONITOR("RemoteConfigurationMonitor"),
   GATEWAY_STATUS_SERVICE("GatewayStatusService"),
   LDAP_SERVICE("LDAPService"),
-  LDAP_ROLES_LOOKUP_SERVICE("LDAPRoleLookupService");
+  LDAP_ROLES_LOOKUP_SERVICE("LDAPRoleLookupService"),
+  KNOXIDF_FEDERATED_IDENTITY_SERVICE("KnoxIDFFederatedIdentityService");
 
   private final String serviceTypeName;
   private final String shortName;
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentity.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentity.java
new file mode 100644
index 000000000..3c025ccb8
--- /dev/null
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentity.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.federation;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+public final class FederatedIdentity {
+
+    private final String id;
+    private final String userId;
+    private final String provider;
+    private final String externalSubject;
+    private final String externalIssuer;
+    private final Instant createdAt;
+    private final Map<String, String> attributes = new HashMap<>();
+
+    public FederatedIdentity(String userId, String provider, String externalSubject, String externalIssuer,
+                             Instant createdAt, Map<String, String> attributes) {
+        this(UUID.randomUUID().toString(), userId, provider, externalSubject, externalIssuer, createdAt, attributes);
+    }
+
+    public FederatedIdentity(String id, String userId, String provider, String externalSubject, String externalIssuer,
+                             Instant createdAt, Map<String, String> attributes) {
+        this.id = id;
+        this.userId = userId;
+        this.provider = provider;
+        this.externalSubject = externalSubject;
+        this.externalIssuer = externalIssuer;
+        this.createdAt = createdAt;
+        if (attributes != null) {
+            this.attributes.putAll(attributes);
+        }
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public String getProvider() {
+        return provider;
+    }
+
+    public String getExternalSubject() {
+        return externalSubject;
+    }
+
+    public String getExternalIssuer() {
+        return externalIssuer;
+    }
+
+    public Instant getCreatedAt() {
+        return createdAt;
+    }
+
+    public Map<String, String> getAttributes() {
+        return attributes;
+    }
+
+    public String getAttribute(String key) {
+        return attributes.get(key);
+    }
+
+    public void addAttribute(String key, String value) {
+        attributes.put(key, value);
+    }
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.java
similarity index 61%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.java
index 17e82e0af..778b589a2 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.java
@@ -5,19 +5,30 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.services.knoxidf.federation;
 
-public enum TokenMetadataType {
+import org.apache.knox.gateway.services.Service;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+import java.util.Optional;
 
+public interface FederatedIdentityService extends Service {
+
+    void addFederatedIdentity(FederatedIdentity identity);
+
+    Optional<FederatedIdentity> findById(String identityId);
+
+    Optional<FederatedIdentity> findByProviderAndSubject(
+            String provider,
+            String externalIssuer,
+            String externalSubject);
 }
+
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java
similarity index 68%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java
index 17e82e0af..30cbce7b2 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java
@@ -5,19 +5,24 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.services.knoxidf.federation;
 
-public enum TokenMetadataType {
+public class FederatedIdentityServiceException extends RuntimeException {
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+    public FederatedIdentityServiceException(String message) {
+        super(message);
+    }
 
+    public FederatedIdentityServiceException(String message, Throwable cause) {
+        super(message, cause);
+    }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java
index c41f983eb..40f9f4712 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java
@@ -29,6 +29,7 @@ public class JWTokenAttributes {
   public static final String DEFAULT_TYPE = "JWT";
   private final String userName;
   private final List<String> audiences;
+  private final long issueTime;
   private final String algorithm;
   private final long expires;
   private final String signingKeystoreName;
@@ -42,22 +43,15 @@ public class JWTokenAttributes {
   private String kid;
   private final String clientId;
   private final List<Map<String, Object>> actorChain;
+  private final Map<String, Object> customAttributes;
 
-  JWTokenAttributes(String userName, List<String> audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias,
-      char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer) {
-    this(userName, audiences, algorithm, expires, signingKeystoreName, signingKeystoreAlias, signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, null);
-  }
-
-  JWTokenAttributes(String userName, List<String> audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias,
-      char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer, String clientId) {
-    this(userName, audiences, algorithm, expires, signingKeystoreName, signingKeystoreAlias, signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, null);
-  }
-
-  JWTokenAttributes(String userName, List<String> audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias,
-      char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer, String clientId, List<Map<String, Object>> actorChain) {
+  JWTokenAttributes(String userName, List<String> audiences, String algorithm, long issueTime, long expires, String signingKeystoreName, String signingKeystoreAlias,
+      char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer, String clientId, List<Map<String, Object>> actorChain,
+                    Map<String, Object> customAttributes) {
     this.userName = userName;
     this.audiences = audiences;
     this.algorithm = algorithm;
+    this.issueTime = issueTime;
     this.expires = expires;
     this.signingKeystoreName = signingKeystoreName;
     this.signingKeystoreAlias = signingKeystoreAlias;
@@ -70,77 +64,81 @@ public class JWTokenAttributes {
     this.issuer = issuer;
     this.clientId = clientId;
     this.actorChain = actorChain;
+    this.customAttributes = customAttributes;
   }
+    public String getUserName() {
+        return userName;
+    }
 
-  public String getUserName() {
-    return userName;
-  }
+    public List<String> getAudiences() {
+        return audiences;
+    }
 
-  public List<String> getAudiences() {
-    return audiences;
-  }
+    public String getAlgorithm() {
+        return algorithm;
+    }
 
-  public String getAlgorithm() {
-    return algorithm;
-  }
+    public long getIssueTime() {
+        return issueTime;
+    }
 
-  public long getExpires() {
-    return expires;
-  }
+    public long getExpires() {
+        return expires;
+    }
 
-  public Date getExpiresDate() {
-    return expires == -1 ? null : new Date(expires);
-  }
+    public Date getExpiresDate() {
+        return expires == -1 ? null : new Date(expires);
+    }
 
-  public String getSigningKeystoreName() {
-    return signingKeystoreName;
-  }
+    public String getSigningKeystoreName() {
+        return signingKeystoreName;
+    }
 
-  public String getSigningKeystoreAlias() {
-    return signingKeystoreAlias;
-  }
+    public String getSigningKeystoreAlias() {
+        return signingKeystoreAlias;
+    }
 
-  public char[] getSigningKeystorePassphrase() {
-    return signingKeystorePassphrase;
-  }
+    public char[] getSigningKeystorePassphrase() {
+        return signingKeystorePassphrase;
+    }
 
-  public boolean isManaged() {
-    return managed;
-  }
+    public boolean isManaged() {
+        return managed;
+    }
 
-  public URI getJkuUri() throws URISyntaxException {
-    return jku != null ? new URI(jku) : null;
-  }
+    public URI getJkuUri() throws URISyntaxException {
+        return jku != null ? new URI(jku) : null;
+    }
 
-  public String getJku(){
-    return jku;
-  }
+    public String getJku() {
+        return jku;
+    }
 
-  public void setJku(String jku) {
-    this.jku = jku;
-  }
+    public void setJku(String jku) {
+        this.jku = jku;
+    }
 
-  public String getType() {
-    return type;
-  }
+    public String getType() {
+        return type;
+    }
 
-  public Set<String> getGroups() {
-    return groups;
-  }
+    public Set<String> getGroups() {
+        return groups;
+    }
 
-  public void setKid(String kid) {
-    this.kid = kid;
-  }
+    public void setKid(String kid) {
+        this.kid = kid;
+    }
 
-  public String getKid() {
-    return kid;
-  }
+    public String getKid() {
+        return kid;
+    }
 
-  public String getIssuer() {
-    return issuer;
-  }
+    public String getIssuer() {
+        return issuer;
+    }
 
-  public String getClientId() {
+    public String getClientId() {
     return clientId;
   }
 
@@ -167,4 +165,8 @@ public class JWTokenAttributes {
   public List<Map<String, Object>> getActorChain() {
     return actorChain;
   }
+
+  public Map<String, Object> getCustomAttributes() {
+      return customAttributes;
+  }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java
index b70a84e6e..8bc70d43d 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java
@@ -28,6 +28,7 @@ public class JWTokenAttributesBuilder {
   private String userName;
   private List<String> audiences;
   private String algorithm;
+  private long issueTime;
   private long expires;
   private String signingKeystoreName;
   private String signingKeystoreAlias;
@@ -40,6 +41,7 @@ public class JWTokenAttributesBuilder {
   private String issuer = JWTokenAttributes.DEFAULT_ISSUER;
   private String clientId;
   private List<Map<String, Object>> actorChain;
+  private Map<String, Object> customAttributes;
 
   public JWTokenAttributesBuilder setUserName(String userName) {
     this.userName = userName;
@@ -60,6 +62,11 @@ public class JWTokenAttributesBuilder {
     return this;
   }
 
+  public JWTokenAttributesBuilder setIssueTime(long issueTime) {
+    this.issueTime = issueTime;
+    return this;
+  }
+
   public JWTokenAttributesBuilder setExpires(long expires) {
     this.expires = expires;
     return this;
@@ -144,8 +151,13 @@ public class JWTokenAttributesBuilder {
     return this;
   }
 
+  public JWTokenAttributesBuilder setCustomAttributes(Map<String, Object> customAttributes) {
+    this.customAttributes = customAttributes;
+    return this;
+  }
+
   public JWTokenAttributes build() {
-    return new JWTokenAttributes(userName, (audiences == null ? new ArrayList<>() : audiences), algorithm, expires, signingKeystoreName, signingKeystoreAlias,
-        signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actorChain);
+    return new JWTokenAttributes(userName, (audiences == null ? new ArrayList<>() : audiences), algorithm, issueTime, expires, signingKeystoreName, signingKeystoreAlias,
+        signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actorChain, customAttributes);
   }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadata.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadata.java
index 3bc7fe2cd..8df35babe 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadata.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadata.java
@@ -70,7 +70,6 @@ public class TokenMetadata {
   }
 
   public TokenMetadata(Map<String, String> metadataMap) {
-    this.metadataMap.clear();
     this.metadataMap.putAll(metadataMap);
   }
 
@@ -151,12 +150,17 @@ public class TokenMetadata {
 
   @JsonIgnore
   public boolean isKnoxSsoCookie() {
-    return getType() == null ? false : TokenMetadataType.KNOXSSO_COOKIE == TokenMetadataType.valueOf(getType());
+    return getType() != null && TokenMetadataType.KNOXSSO_COOKIE == TokenMetadataType.valueOf(getType());
   }
 
   @JsonIgnore
   public boolean isClientId() {
-    return getType() == null ? false : TokenMetadataType.CLIENT_ID == TokenMetadataType.valueOf(getType());
+    return getType() != null && TokenMetadataType.CLIENT_ID == TokenMetadataType.valueOf(getType());
+  }
+
+  @JsonIgnore
+  public boolean isAuthCode() {
+    return getType() != null && TokenMetadataType.AUTH_CODE == TokenMetadataType.valueOf(getType());
   }
 
   public String getType() {
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
index 17e82e0af..4d0080fd5 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
@@ -18,6 +18,6 @@ package org.apache.knox.gateway.services.security.token;
 
 public enum TokenMetadataType {
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY, AUTH_CODE, REFRESH_TOKEN;
 
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java
index 72fb7a25f..7805253b0 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java
@@ -35,6 +35,8 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.text.ParseException;
+import java.util.UUID;
 
 public class TokenUtils {
   public static final String ATTR_CURRENT_KNOXSSO_COOKIE_TOKEN_ID = "currentKnoxSsoCookieTokenId";
@@ -53,6 +55,21 @@ public class TokenUtils {
     return token.getClaim(JWTToken.KNOX_ID_CLAIM);
   }
 
+  /**
+   * If the supplied 'token' conforms the UUID string representation, we consider
+   * that as the token ID; otherwise we expect that 'token' is the entire JWT, and
+   * we get the token ID from it
+   */
+  public static String getTokenId(String token) throws ParseException {
+    try {
+      UUID.fromString(token);
+      return token;
+    } catch (IllegalArgumentException e) {
+      //NOP: the supplied token is not a UUID, we expect the entire JWT
+    }
+    return getTokenId(new JWTToken(token));
+  }
+
   /**
    * Determine if server-managed token state is enabled for a provider, based on configuration.
    * The analysis includes checking the provider params and the gateway configuration.
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
index 4cb4d151e..d756b034c 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
@@ -23,6 +23,7 @@ import com.nimbusds.jose.JOSEObjectType;
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.JWSSigner;
 import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jwt.JWTClaimsSet;
 
 public interface JWT {
 
@@ -63,6 +64,8 @@ public interface JWT {
 
   String getClaims();
 
+  JWTClaimsSet  getJWTClaimsSet();
+
   JWSAlgorithm getSignatureAlgorithm();
 
   JOSEObjectType getType();
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java
index b43a0b29a..78ad648f0 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java
@@ -18,6 +18,7 @@ package org.apache.knox.gateway.services.security.token.impl;
 
 import java.net.URISyntaxException;
 import java.text.ParseException;
+import java.time.Instant;
 import java.util.Date;
 import java.util.Map;
 import java.util.UUID;
@@ -84,6 +85,7 @@ public class JWTToken implements JWT {
     }
     JWTClaimsSet claims;
     JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
+            .issueTime(Date.from(Instant.ofEpochMilli(jwtAttributes.getIssueTime())))
             .issuer(jwtAttributes.getIssuer())
             .subject(jwtAttributes.getUserName())
             .audience(jwtAttributes.getAudiences());
@@ -114,6 +116,11 @@ public class JWTToken implements JWT {
     builder.claim(KNOX_ID_CLAIM, String.valueOf(UUID.randomUUID()));
 
     builder.claim(MANAGED_TOKEN_CLAIM, String.valueOf(jwtAttributes.isManaged()));
+
+    if (jwtAttributes.getCustomAttributes() != null) {
+      jwtAttributes.getCustomAttributes().forEach(builder::claim);
+    }
+
     claims = builder.build();
 
     jwt = new SignedJWT(header, claims);
@@ -148,6 +155,16 @@ public class JWTToken implements JWT {
     return c;
   }
 
+  @Override
+  public JWTClaimsSet getJWTClaimsSet() {
+    try {
+      return jwt.getJWTClaimsSet();
+    } catch (ParseException e) {
+      log.unableToParseToken(e);
+      return null;
+    }
+  }
+
   @Override
   public String getPayload() {
     Payload payload = jwt.getPayload();
diff --git a/gateway-util-common/pom.xml b/gateway-util-common/pom.xml
index 1eec58fe9..88d9c9311 100644
--- a/gateway-util-common/pom.xml
+++ b/gateway-util-common/pom.xml
@@ -104,6 +104,23 @@
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpclient</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>javax.ws.rs</groupId>
+            <artifactId>javax.ws.rs-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-text</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
         <!-- ********** ********** ********** ********** ********** ********** -->
         <!-- ********** Test Dependencies                           ********** -->
         <!-- ********** ********** ********** ********** ********** ********** -->
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/JsonUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/JsonUtils.java
index f0a8bf177..ab49f7d63 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/JsonUtils.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/JsonUtils.java
@@ -23,6 +23,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Map;
 
+import com.fasterxml.jackson.databind.ObjectWriter;
 import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
 import org.apache.knox.gateway.i18n.GatewayUtilCommonMessages;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
@@ -37,12 +38,16 @@ public class JsonUtils {
   private static final GatewayUtilCommonMessages LOG = MessagesFactory.get( GatewayUtilCommonMessages.class );
 
   public static String renderAsJsonString(Map<String, Object> map) {
+    return renderAsJsonString(map, false);
+  }
+
+  public static String renderAsJsonString(Map<String, Object> map, boolean pretty) {
     String json = null;
     ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
 
     try {
-      // write JSON to a file
-      json = mapper.writeValueAsString(map);
+      final ObjectWriter writer = pretty ? mapper.writerWithDefaultPrettyPrinter() :  mapper.writer();
+      json = writer.writeValueAsString(map);
     } catch ( JsonProcessingException e ) {
       LOG.failedToSerializeMapToJSON( map, e );
     }
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java
new file mode 100644
index 000000000..239e0baf2
--- /dev/null
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+import javax.ws.rs.core.Response;
+import java.util.Set;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
+
+public final class AuthorizeRequestMetadata {
+    private final String clientId;
+    private final String subject;
+    private final String responseType;
+    private final String redirectUri;
+    private final Set<String> requestedScopes;
+    private final String state;
+    private final String nonce;
+    private final String codeChallenge;
+    private final String codeChallengeMethod;
+
+    public AuthorizeRequestMetadata(String clientId, String subject, String responseType, String redirectUri, Set<String> requestedScopes, String state, String nonce) {
+        this(clientId, subject, responseType, redirectUri, requestedScopes, state, nonce, null, null);
+    }
+
+    public AuthorizeRequestMetadata(String clientId, String subject, String responseType, String redirectUri, Set<String> requestedScopes, String state, String nonce, String codeChallenge, String codeChallengeMethod) {
+        this.clientId = clientId;
+        this.subject = subject;
+        this.responseType = responseType;
+        this.redirectUri = redirectUri;
+        this.requestedScopes = requestedScopes;
+        this.state = state;
+        this.nonce = nonce;
+        this.codeChallenge = codeChallenge;
+        this.codeChallengeMethod = codeChallengeMethod;
+    }
+
+    public Response verify() {
+        if (responseType == null || responseType.isEmpty()) {
+            return error("invalid_request", "Missing response_type");
+        } else {
+            if (!KnoxIDFConstants.ALLOWED_RESPONSE_TYPES.contains(responseType)) {
+                return error("unsupported_response_type", "Unsupported response_type");
+            }
+
+            boolean requiresNonce = responseType.contains("id_token");
+            if (requiresNonce && (nonce == null || nonce.isEmpty())) {
+                return error("invalid_request", "Missing required parameter: nonce");
+            }
+        }
+
+        if (clientId == null || clientId.isEmpty()) {
+            return error("invalid_request", "Missing client_id");
+        }
+
+        // Verify redirect URI
+        if (redirectUri == null || redirectUri.isEmpty()) {
+            return error("invalid_request", "Missing redirect_uri");
+        }
+
+        // Verify scope(s)
+        if (requestedScopes == null || requestedScopes.isEmpty()) {
+            return error("invalid_scope", "Missing scopes");
+        } else if (!requestedScopes.contains("openid")) {
+            return error("invalid_scope", "Missing required scope: openid");
+        }
+
+        return null;
+    }
+
+    public String getClientId() {
+        return clientId;
+    }
+
+    public String getSubject() {
+        return subject;
+    }
+
+    public String getResponseType() {
+        return responseType;
+    }
+
+    public String getRedirectUri() {
+        return redirectUri;
+    }
+
+    public String getState() {
+        return state;
+    }
+
+    public String getNonce() {
+        return nonce;
+    }
+
+    public String getCodeChallenge() {
+        return codeChallenge;
+    }
+
+    public String getCodeChallengeMethod() {
+        return codeChallengeMethod;
+    }
+
+    public Set<String> getRequestedScopes() {
+        return requestedScopes;
+    }
+
+    public String getJoinedRequestedScopes() {
+        return String.join(" ", requestedScopes);
+    }
+
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java
similarity index 60%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java
index 17e82e0af..80f3d7110 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java
@@ -5,19 +5,29 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.util.knoxidf;
 
-public enum TokenMetadataType {
+public class AuthorizeRequestMetadataStore extends KnoxIDFArtifactStore<AuthorizeRequestMetadata>{
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+    private static AuthorizeRequestMetadataStore instance;
 
+    private AuthorizeRequestMetadataStore(long ttl) {
+        super(ttl);
+    }
+
+    public static synchronized AuthorizeRequestMetadataStore getInstance(long ttl) {
+        if (instance == null) {
+            instance = new AuthorizeRequestMetadataStore(ttl);
+        }
+        return instance;
+    }
 }
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java
new file mode 100644
index 000000000..7ad607843
--- /dev/null
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+import javax.servlet.ServletContext;
+
+public class FederatedOpConfiguration {
+    private final boolean enabled;
+    private final String name;
+    private final String clientId;
+    private final String clientSecret;
+    private final String tokenEndpoint;
+    private final String authorizeEndpoint;
+    private final String userInfoEndpoint;
+    private final String discoveryEndpoint;
+    private final String authorizeCallback;
+
+    public FederatedOpConfiguration(final ServletContext servletContext, final String opName) {
+        this.name = opName;
+        final String prefix = KnoxIDFConstants.FEDERATED_OP_CONFIG_PREFIX + (opName != null ? opName + "." : "");
+        this.enabled = Boolean.parseBoolean(servletContext.getInitParameter(prefix + "enabled"));
+        this.clientId = servletContext.getInitParameter(prefix + "clientId");
+        this.clientSecret = servletContext.getInitParameter(prefix + "clientSecret");
+        this.tokenEndpoint = servletContext.getInitParameter(prefix + "token.endpoint");
+        this.authorizeEndpoint = servletContext.getInitParameter(prefix + "authorize.endpoint");
+        this.authorizeCallback = servletContext.getInitParameter(prefix + "authorize.callback");
+        this.userInfoEndpoint = servletContext.getInitParameter(prefix + "userinfo.endpoint");
+        this.discoveryEndpoint = servletContext.getInitParameter(prefix + "discovery.endpoint");
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public String getClientId() {
+        return clientId;
+    }
+
+    public String getClientSecret() {
+        return clientSecret;
+    }
+
+    String getAuthorizeEndpoint() {
+        return authorizeEndpoint;
+    }
+
+    public String getAuthorizeCallback() {
+        return authorizeCallback;
+    }
+
+    public String getTokenEndpoint() {
+        return tokenEndpoint;
+    }
+
+    public String getUserInfoEndpoint() {
+        return userInfoEndpoint;
+    }
+
+    public String getDiscoveryEndpoint() {
+        return discoveryEndpoint;
+    }
+
+}
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationFactory.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationFactory.java
new file mode 100644
index 000000000..f1efc3d75
--- /dev/null
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationFactory.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+import javax.servlet.ServletContext;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public class FederatedOpConfigurationFactory {
+
+    public static Map<String, FederatedOpConfiguration> createFederatedOpConfiguration(final ServletContext servletContext) {
+        final String names = servletContext.getInitParameter(KnoxIDFConstants.FEDERATED_OP_CONFIG_NAMES);
+        if (names == null || names.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        final Map<String, FederatedOpConfiguration> configs = new HashMap<>();
+        for (String name : names.split(",")) {
+            final String trimmedName = name.trim();
+            final FederatedOpConfiguration federatedOpConfiguration = new FederatedOpConfiguration(servletContext, trimmedName);
+            if (federatedOpConfiguration.isEnabled()) {
+                configs.put(trimmedName, federatedOpConfiguration);
+            }
+        }
+        return configs;
+    }
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.java
similarity index 59%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.java
index 17e82e0af..80bbb1a04 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.java
@@ -5,19 +5,31 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.util.knoxidf;
 
-public enum TokenMetadataType {
+import java.util.Set;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+public class FederatedOpConfigurationStore extends KnoxIDFArtifactStore<Set<FederatedOpConfiguration>> {
 
+    private static FederatedOpConfigurationStore instance;
+
+    private FederatedOpConfigurationStore(long ttl) {
+        super(ttl);
+    }
+
+    public static synchronized FederatedOpConfigurationStore getInstance(long ttl) {
+        if (instance == null) {
+            instance = new FederatedOpConfigurationStore(ttl);
+        }
+        return instance;
+    }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java
similarity index 56%
copy from gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
copy to gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java
index 17e82e0af..dbeba8141 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenMetadataType.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java
@@ -5,19 +5,35 @@
  * licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
- *
+ * <p>
  * http://www.apache.org/licenses/LICENSE-2.0
- *
+ * <p>
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  * License for the specific language governing permissions and limitations under
  * the License.
  */
-package org.apache.knox.gateway.services.security.token;
+package org.apache.knox.gateway.util.knoxidf;
 
-public enum TokenMetadataType {
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
 
-  JWT, KNOXSSO_COOKIE, CLIENT_ID, API_KEY;
+import java.util.concurrent.TimeUnit;
 
+public abstract class KnoxIDFArtifactStore<T> {
+
+    private final Cache<String, T> cache;
+
+    protected KnoxIDFArtifactStore(long ttl) {
+        this.cache = Caffeine.newBuilder().expireAfterWrite(ttl * 2, TimeUnit.MILLISECONDS).build();
+    }
+
+    public void put(String key, T value) {
+        cache.put(key, value);
+    }
+
+    public T get(String key) {
+        return cache.getIfPresent(key);
+    }
 }
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
new file mode 100644
index 000000000..5573b90e2
--- /dev/null
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+import com.google.common.collect.Sets;
+
+import java.util.Set;
+
+public interface KnoxIDFConstants {
+    String BASE_RESORCE_PATH = "knoxidf/api/v1";
+    String AUTH_CODE = "authorization_code";
+    String CLIENT_ID = "client_id";
+    String REDIRECT_URI = "redirect_uri";
+    String REDIRECT_URIS = "redirect_uris";
+    String RESPONSE_TYPE = "response_type";
+    Set<String> ALLOWED_RESPONSE_TYPES = Sets.newHashSet("code", "id_token", "code id_token");
+    String SCOPE = "scope";
+    String ALLOWED_SCOPES = "allowed_scopes";
+    String OFFLINE_ACCESS_SCOPE = "offline_access";
+    Set<String> DEFAULT_SCOPES = Sets.newHashSet("openid", "profile", "email", OFFLINE_ACCESS_SCOPE);
+    String OPENID_SCOPE = SCOPE + "=openid";
+    String STATE = "state";
+    String CODE = "code";
+    String REFRESH_TOKEN = "refresh_token";
+    String REFRESH_TOKEN_TTL= "refresh.token.ttl";
+    long REFRESH_TOKEN_TTL_DEFAULT = 86400000L; // 1 day
+    String CODE_RESPONSE_TYPE = RESPONSE_TYPE + "=" + CODE;
+    String NONCE = "nonce";
+
+    String CODE_CHALLENGE = "code_challenge";
+    String CODE_CHALLENGE_METHOD = "code_challenge_method";
+    String CODE_VERIFIER = "code_verifier";
+    String PKCE_METHOD_S256 = "S256";
+    String PKCE_METHOD_PLAIN = "plain";
+
+    String TOKEN_ID_ATTRIBUTE = "X-Token-Id";
+    String SCOPE_ATTRIBUTE = "X-Token-Scope";
+
+    String FEDERATED_IDENTITY_ID = "federated_identity_id";
+    String FEDERATED_ID_TOKEN_PREFIX = "fed_id_";
+    String FEDERATED_ACCESS_TOKEN_PREFIX = "fed_access_";
+    String FEDERATED_OP_CONFIG_PREFIX = "federated.op.";
+    String FEDERATED_OP_CONFIG_NAMES = FEDERATED_OP_CONFIG_PREFIX + "names";
+
+    String TOKEN_EXCHANGE_TOPOLOGY_NAME = "token.exchange.topology.name";
+}
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
new file mode 100644
index 000000000..989a17e61
--- /dev/null
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.util.knoxidf;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.text.StringEscapeUtils;
+import org.apache.knox.gateway.util.JsonUtils;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+
+public class KnoxIDFUtils {
+
+    private static final int CHUNK_SIZE = 255;
+
+    public static Map<String, String> splitFederatedToken(String token, boolean idToken) {
+        final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX;
+        final Map<String, String> parts = new LinkedHashMap<>();
+        int i = 0, part = 1;
+        while (i < token.length()) {
+            int end = Math.min(i + CHUNK_SIZE, token.length());
+            parts.put(prefix + part++, token.substring(i, end));
+            i = end;
+        }
+        return parts;
+    }
+
+    public static String joinFederatedToken(Map<String, String> tokenMetadataMap, boolean idToken) {
+        final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX;
+        return tokenMetadataMap.entrySet().stream()
+                .filter(e -> e.getKey().startsWith(prefix))
+                .sorted(Map.Entry.comparingByKey(
+                        Comparator.comparingInt(k -> Integer.parseInt(k.replace(prefix, "")))
+                ))
+                .map(Map.Entry::getValue)
+                .collect(Collectors.joining());
+    }
+
+    public static Response error(String error, String description) {
+        final Map<String, String> errorMap = new HashMap<>();
+        errorMap.put("error", error);
+        errorMap.put("error_description", description);
+        return Response.status(Response.Status.UNAUTHORIZED).entity(JsonUtils.renderAsJsonString(errorMap)).build();
+    }
+
+    public static String getRequestParamSafe(final HttpServletRequest request, final String key) {
+        String value = request.getParameter(key);
+        if (value == null) {
+            return "";
+        } else {
+            return StringEscapeUtils.escapeHtml4(value);
+        }
+    }
+
+    public static Set<FederatedOpConfiguration> fetchEnabledFederatedOpConfigs(final HttpServletRequest request) {
+        final ServletContext servletContext = request.getServletContext();
+        return servletContext == null ? Collections.emptySet() : new HashSet<>(FederatedOpConfigurationFactory.createFederatedOpConfiguration(servletContext).values());
+    }
+
+    public static AuthorizeRequestMetadata buildAuthRequestMetadata(final HttpServletRequest request) {
+        final String clientId = request.getParameter(KnoxIDFConstants.CLIENT_ID);
+        final String responseType = request.getParameter(KnoxIDFConstants.RESPONSE_TYPE);
+        final String redirectUri = request.getParameter(KnoxIDFConstants.REDIRECT_URI);
+        final String scope = request.getParameter(KnoxIDFConstants.SCOPE);
+        final Set<String> requestedScopes = StringUtils.isBlank(scope) ? KnoxIDFConstants.DEFAULT_SCOPES : new HashSet<>(Arrays.asList(scope.split("\\s+")));
+        final String state = request.getParameter(KnoxIDFConstants.STATE);
+        final String nonce = request.getParameter(KnoxIDFConstants.NONCE);
+        final String codeChallenge = request.getParameter(KnoxIDFConstants.CODE_CHALLENGE);
+        final String codeChallengeMethod = request.getParameter(KnoxIDFConstants.CODE_CHALLENGE_METHOD);
+        return new AuthorizeRequestMetadata(clientId, null, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod);
+    }
+
+    public static String buildFederatedOpAuthRedirect(final FederatedOpConfiguration federatedOpConfiguration, final String federatedState) {
+        return federatedOpConfiguration.getAuthorizeEndpoint()
+                + "?" + KnoxIDFConstants.CLIENT_ID + "=" + federatedOpConfiguration.getClientId()
+                + "&" + KnoxIDFConstants.REDIRECT_URI + "=" + federatedOpConfiguration.getAuthorizeCallback()
+                + "&" + KnoxIDFConstants.CODE_RESPONSE_TYPE
+                + "&" + KnoxIDFConstants.OPENID_SCOPE
+                + "&" + KnoxIDFConstants.STATE + "=" + federatedState;
+    }
+
+}
diff --git a/pom.xml b/pom.xml
index e0e43c6e6..adf8ff7f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -149,6 +149,7 @@
         <module>gateway-service-metadata</module>
         <module>gateway-service-session</module>
         <module>gateway-openapi-ui</module>
+        <module>gateway-service-knoxidf</module>
     </modules>
 
     <properties>
@@ -289,6 +290,7 @@
         <taglibs-standard.version>1.2.5</taglibs-standard.version>
         <testcontainers.version>1.15.1</testcontainers.version>
         <txw2.version>2.4.0-b180830.0438</txw2.version>
+        <uuid.generator.version>5.2.0</uuid.generator.version>
         <velocity.version>2.4.1</velocity.version>
         <woodstox-core.version>6.4.0</woodstox-core.version>
         <xmlsec.version>4.0.4</xmlsec.version>
@@ -1428,6 +1430,11 @@
                 <artifactId>knox-token-generation-ui</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.apache.knox</groupId>
+                <artifactId>gateway-service-knoxidf</artifactId>
+                <version>${project.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.glassfish.jersey.containers</groupId>
                 <artifactId>jersey-container-servlet-core</artifactId>
@@ -1443,6 +1450,11 @@
                 <artifactId>jersey-server</artifactId>
                 <version>${jersey.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.glassfish.jersey.core</groupId>
+                <artifactId>jersey-common</artifactId>
+                <version>${jersey.version}</version>
+            </dependency>
 
             <dependency>
                 <groupId>org.glassfish.jersey.inject</groupId>
@@ -2043,6 +2055,11 @@
                 <artifactId>woodstox-core</artifactId>
                 <version>${woodstox-core.version}</version>
             </dependency>
+            <dependency>
+                <groupId>com.fasterxml.uuid</groupId>
+                <artifactId>java-uuid-generator</artifactId>
+                <version>${uuid.generator.version}</version>
+            </dependency>
 
             <dependency>
                 <groupId>cglib</groupId>


(knox) 37/37: KNOX-3414: support Authorization Code + PKCE for public clients

Posted by sm...@apache.org.
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`.
+
+![Polaris Console sign-in](../../assets/images/knoxidf/polaris_console_login.png)
+
+Clicking **Sign in with OIDC** redirects the browser to `knoxidf-sso`'s `/authorize`. With no SSO
+cookie yet, KnoxSSO shows its login form; after a successful LDAP login the browser returns to
+`/authorize`, which issues a code and redirects to `http://localhost:5173/auth/callback?code=…`.
+The Console then exchanges the code (with its PKCE `code_verifier`) at `knoxidf-token`'s `/token`,
+stores the access token in memory, and lands on the dashboard.
+
+![Polaris Console home after login](../../assets/images/knoxidf/polaris_console_home.png)
+
+## 9. Verify
+
+With the token in hand, the Console calls the Polaris API. Navigating to **Catalogs** should list
+the catalog created by the getting-started setup:
+
+```bash
+# The same call the Console makes (token minted via the browser flow):
+curl -s 'http://localhost:8181/api/management/v1/catalogs' \
+  -H "Authorization: Bearer <access_token>" \
+  -H "Polaris-Realm: realm-external" | jq .
+```
+
+A populated **Catalogs** page (and a `200` from the call above) confirms the full chain:
+interactive login → Knox-issued token → Polaris accepting it.
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| `ERR_TOO_MANY_REDIRECTS` between KnoxSSO and `/authorize` | `knoxsso.token.issuer` ≠ `knoxidf-sso` `jwt.expected.issuer` (often `http` vs `https`) | Make the two issuer strings byte-for-byte identical; clear the stale `hadoop-jwt` cookie. |
+| CORS error on `/.well-known/openid-configuration` | No CORS provider on `knoxidf-sso` | Add the WebAppSec CORS provider (surface #1), origin `http://localhost:5173`. |
+| CORS error on `POST /token` | No CORS provider on `knoxidf-token` | Add the WebAppSec CORS provider (surface #2). |
+| CORS error on `/api/management/**` or `/api/catalog/**` | Polaris (Quarkus) sends no CORS headers | Add the `quarkus.http.cors.*` block (surface #3) and re-create the container. |
+| OPTIONS returns `200` but browser still blocks; log shows `Unrecognized configuration key "quarkus.http.cors"` | Wrong Quarkus key | Use `quarkus.http.cors.enabled`, not `quarkus.http.cors`. |
+| Preflight fails only when the app sends `Polaris-Realm` | Header not in the allow-list | Add `Polaris-Realm` to `quarkus.http.cors.headers`. |
+| `/authorize` cannot log in | KnoxSSO cannot reach its user store | Ensure the LDAP/identity store in `knoxsso` is running and reachable (`ldap://localhost:33389` in the demo). |
+| `redirect_uri` rejected at `/authorize` | Callback not registered / mismatch | Register `http://localhost:5173/auth/callback` and set the identical value in `VITE_OIDC_REDIRECT_URI`. |
+| Token accepted but `403` from Polaris | Claim → role/principal mapping | Check `knoxidf.knox.token.hardcoded.claim.mappings` vs Polaris' `role-claim-path` / `principal-mapper`. |
+| Header shows `User` instead of the signed-in name | No persisted Polaris principal matches the token `sub` | Create a principal named after `sub` (e.g. `admin`) — see [Create a principal so the Console can show the signed-in user](#create-a-principal-so-the-console-can-show-the-signed-in-user). |
+
+## Next steps
+
+- [Client Credentials integration](polaris.md) — the machine-to-machine counterpart to this flow.
+- [Endpoint Reference](../endpoints.md) — `/authorize`, `/token`, discovery, and PKCE details.
+- [Federation](../federation.md) — front `knoxidf-sso` with upstream OpenID Providers.
+- [Security](../security.md) — dynamic client registration and hardening.
diff --git a/knox-site/mkdocs.yml b/knox-site/mkdocs.yml
index c0f91bdbb..46e02e2f8 100644
--- a/knox-site/mkdocs.yml
+++ b/knox-site/mkdocs.yml
@@ -122,6 +122,9 @@ nav:
       - Security: knoxidf/security.md
       - Federation: knoxidf/federation.md
       - Operations: knoxidf/operations.md
+      - Integrations:
+          - Apache Polaris (Client Credentials): knoxidf/integrations/polaris.md
+          - Apache Polaris Console (Authorization Code): knoxidf/integrations/polaris_console.md
   - Developer Guide:
       - Overview: dev-guide/book.md
       - Extending Knox:


(knox) 13/37: KNOX-3408 - Move act-claim/ActorChainPrincipal coverage into TokenExchangeHandlerTest; drop redundant JWTFederationFilterHandleTokenExchangeTest

Posted by sm...@apache.org.
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 baefcd58ac3843695fbc838a768aa966d6eed6c1
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Mon Aug 10 16:58:20 2026 +0200

    KNOX-3408 - Move act-claim/ActorChainPrincipal coverage into TokenExchangeHandlerTest; drop redundant JWTFederationFilterHandleTokenExchangeTest
---
 ...JWTFederationFilterHandleTokenExchangeTest.java | 262 ---------------------
 .../jwt/filter/TokenExchangeHandlerTest.java       |  29 +++
 2 files changed, 29 insertions(+), 262 deletions(-)

diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
deleted file mode 100644
index ca23a5ae7..000000000
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
+++ /dev/null
@@ -1,262 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with this
- * work for additional information regarding copyright ownership. The ASF
- * licenses this file to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- * <p>
- * http://www.apache.org/licenses/LICENSE-2.0
- * <p>
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
- * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
- * License for the specific language governing permissions and limitations under
- * the License.
- */
-package org.apache.knox.gateway.provider.federation;
-
-import com.nimbusds.jose.crypto.RSASSASigner;
-import com.nimbusds.jwt.SignedJWT;
-import org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter;
-import org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter;
-import org.apache.knox.gateway.security.ActorChainPrincipal;
-import org.apache.knox.gateway.security.CommonTokenConstants;
-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.TokenExchangePrincipal;
-import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
-import org.apache.knox.gateway.services.security.token.impl.JWTToken;
-import org.easymock.EasyMock;
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Test;
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.security.Principal;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-import java.util.Properties;
-import java.util.Set;
-
-import static org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter.JWT_DEFAULT_ISSUER;
-
-/**
- * Unit tests for the {@link JWTFederationFilter#handleTokenExchange} method (OIDC
- * delegation path). Each test verifies the Subject constructed.
- *
- * <p>These tests use {@link TestJWTFederationFilter} with {@link TestJWTokenAuthority} (static key,
- * no mocking needed — both tokens use Knox issuer {@code JWT_DEFAULT_ISSUER} which is in the
- * static expected-issuers list).
- *
- * <p>The filter's {@code continueWithEstablishedSecurityContext} runs
- * {@code Subject.doAs(subject, () -> chain.doFilter(request, response))}. The
- * {@link AbstractJWTFilterTest.TestFilterChain} captures {@code SubjectUtils.getCurrentSubject()}
- * from within that doAs context, which is exactly the Subject built. All principal
- * assertions use {@code chain.subject.getPrincipals(XxxPrincipal.class)}.
- */
-public class JWTFederationFilterHandleTokenExchangeTest extends AbstractJWTFilterTest {
-
-  static final String ACTOR_ISSUER = "https://actor.oidc.example.com";
-
-  @Before
-  public void setUp() throws Exception {
-    handler = new TestJWTFederationFilter();
-    ((TestJWTFederationFilter) handler).setTokenService(new TestJWTokenAuthority(publicKey));
-    handler.init(new TestFilterConfig(getProperties()));
-  }
-
-  @Override
-  protected void setTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
-    EasyMock.expect(request.getHeader("Authorization"))
-        .andReturn(JWTFederationFilter.BEARER + jwt.serialize()).anyTimes();
-  }
-
-  @Override
-  protected void setGarbledTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
-    EasyMock.expect(request.getHeader("Authorization"))
-        .andReturn(JWTFederationFilter.BEARER + "ljm" + jwt.serialize()).anyTimes();
-  }
-
-  @Override
-  protected String getAudienceProperty() {
-    return JWTFederationFilter.KNOX_TOKEN_AUDIENCES;
-  }
-
-  @Override
-  protected String getVerificationPemProperty() {
-    return JWTFederationFilter.TOKEN_VERIFICATION_PEM;
-  }
-
-  /**
-   * When both subject_token and actor_token are present, the filter establishes the actor
-   * (from actor_token.sub) as the PrimaryPrincipal in the resulting Subject.
-   */
-  @Test
-  public void testActorAndSubjectTokensSetActorAsPrimaryPrincipal() throws Exception {
-    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
-        new Date(System.currentTimeMillis() + 60000), privateKey);
-    SignedJWT actorJwt = getJWT(JWT_DEFAULT_ISSUER, "actor-svc",
-        new Date(System.currentTimeMillis() + 60000), privateKey);
-
-    HttpServletRequest request = buildTokenExchangeRequest(subjectJwt.serialize(), actorJwt.serialize());
-    EasyMock.replay(request);
-    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
-    EasyMock.replay(response);
-
-    TestFilterChain chain = new TestFilterChain();
-    handler.doFilter(request, response, chain);
-
-    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
-    Set<PrimaryPrincipal> principals = chain.subject.getPrincipals(PrimaryPrincipal.class);
-    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, principals.size());
-    Assert.assertEquals("Expected actor as PrimaryPrincipal", "actor-svc",
-        ((Principal) principals.toArray()[0]).getName());
-  }
-
-  /**
-   * When subject_token and actor_token have different issuers, the filter creates a
-   * TokenExchangePrincipal that carries the subject and actor identities with their respective
-   * issuers. Using different issuers ensures that all four TEP fields can be asserted
-   * unambiguously.
-   *
-   * <p>Both issuers are added to the static {@code jwt.expected.issuer} whitelist —
-   * {@code TestJWTokenAuthority} accepts either token because they are signed with
-   * the same test key.
-   */
-  @Test
-  public void testActorAndSubjectTokensCreateTokenExchangePrincipal() throws Exception {
-    Properties props = getProperties();
-    props.setProperty(AbstractJWTFilter.JWT_EXPECTED_ISSUER, JWT_DEFAULT_ISSUER + "," + ACTOR_ISSUER);
-    handler.init(new TestFilterConfig(props));
-
-    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
-        new Date(System.currentTimeMillis() + 60000), privateKey);
-    SignedJWT actorJwt = getJWT(ACTOR_ISSUER, "actor-svc",
-        new Date(System.currentTimeMillis() + 60000), privateKey);
-
-    HttpServletRequest request = buildTokenExchangeRequest(subjectJwt.serialize(), actorJwt.serialize());
-    EasyMock.replay(request);
-    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
-    EasyMock.replay(response);
-
-    TestFilterChain chain = new TestFilterChain();
-    handler.doFilter(request, response, chain);
-
-    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
-    TokenExchangePrincipal tep = SubjectUtils.getTokenExchangePrincipal(chain.subject);
-    Assert.assertNotNull("TokenExchangePrincipal should be present", tep);
-    Assert.assertEquals("Subject principal name", "k8s-sa", tep.getSubjectPrincipalName());
-    Assert.assertEquals("Actor principal name", "actor-svc", tep.getActorPrincipalName());
-    Assert.assertEquals("Subject issuer", JWT_DEFAULT_ISSUER, tep.getSubjectIssuer());
-    Assert.assertEquals("Actor issuer", ACTOR_ISSUER, tep.getActorIssuer());
-  }
-
-  /**
-   * When subject_token carries an {@code act} claim (a prior delegation chain), the filter
-   * extracts it and creates an {@code ActorChainPrincipal} in the resulting Subject so that
-   * the delegation history is preserved through the filter pipeline.
-   */
-  @Test
-  public void testSubjectTokenWithActClaimCreatesActorChainPrincipal() throws Exception {
-    List<Map<String, Object>> actorChainData = List.of(Map.of("sub", "prior-actor"));
-    JWTToken subjectToken = new JWTToken(new JWTokenAttributesBuilder()
-        .setUserName("k8s-sa")
-        .setIssuer(JWT_DEFAULT_ISSUER)
-        .setAlgorithm("RS256")
-        .setExpires(System.currentTimeMillis() + 60000)
-        .setActorChain(actorChainData)
-        .build());
-    subjectToken.sign(new RSASSASigner(privateKey));
-
-    SignedJWT actorJwt = getJWT(JWT_DEFAULT_ISSUER, "actor-svc",
-        new Date(System.currentTimeMillis() + 60000), privateKey);
-
-    HttpServletRequest request = buildTokenExchangeRequest(subjectToken.toString(), actorJwt.serialize());
-    EasyMock.replay(request);
-    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
-    EasyMock.replay(response);
-
-    TestFilterChain chain = new TestFilterChain();
-    handler.doFilter(request, response, chain);
-
-    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
-    Set<ActorChainPrincipal> actorChainPrincipals = chain.subject.getPrincipals(ActorChainPrincipal.class);
-    Assert.assertFalse("ActorChainPrincipal should be present", actorChainPrincipals.isEmpty());
-    ActorChainPrincipal acp = actorChainPrincipals.iterator().next();
-    Assert.assertEquals("Expected current actor from act claim", "prior-actor", acp.getCurrentActor());
-  }
-
-  /**
-   * With no actor_token provided, the filter proceeds successfully and the resulting Subject
-   * has the subject itself as PrimaryPrincipal, no TokenExchangePrincipal, and no
-   * ImpersonatedPrincipal.
-   */
-  @Test
-  public void testSubjectTokenOnlySucceeds() throws Exception {
-    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
-        new Date(System.currentTimeMillis() + 60000), privateKey);
-
-    HttpServletRequest request = buildTokenExchangeRequestSubjectOnly(subjectJwt.serialize());
-    EasyMock.replay(request);
-    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
-    EasyMock.replay(response);
-
-    TestFilterChain chain = new TestFilterChain();
-    handler.doFilter(request, response, chain);
-
-    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
-    Set<PrimaryPrincipal> principals = chain.subject.getPrincipals(PrimaryPrincipal.class);
-    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, principals.size());
-    Assert.assertEquals("Subject should be its own PrimaryPrincipal", "k8s-sa",
-        ((java.security.Principal) principals.toArray()[0]).getName());
-    Assert.assertNull("No TokenExchangePrincipal expected for subject-only exchange",
-        SubjectUtils.getTokenExchangePrincipal(chain.subject));
-    Assert.assertTrue("ImpersonatedPrincipal set should be empty",
-        chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
-  }
-
-  /**
-   * Builds a token-exchange request mock with both subject_token and actor_token parameters.
-   * The caller must call {@code EasyMock.replay(request)} before using the returned mock.
-   *
-   * @param subjectToken serialized subject JWT
-   * @param actorToken   serialized actor JWT
-   * @return a NiceMock HttpServletRequest configured for a token-exchange grant
-   */
-  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, String actorToken) {
-    HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
-    EasyMock.expect(request.getRequestURL())
-        .andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
-    EasyMock.expect(request.getParameter(CommonTokenConstants.GRANT_TYPE))
-        .andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
-    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN))
-        .andReturn(subjectToken).anyTimes();
-    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN))
-        .andReturn(actorToken).anyTimes();
-    return request;
-  }
-
-  /**
-   * Builds a token-exchange request mock with subject_token only. The actor_token parameter
-   * is not mocked, so the NiceMock returns null for {@code getParameter(ACTOR_TOKEN)}.
-   * The caller must call {@code EasyMock.replay(request)} before using the returned mock.
-   *
-   * @param subjectToken serialized subject JWT
-   * @return a NiceMock HttpServletRequest configured for a subject-only token-exchange grant
-   */
-  private HttpServletRequest buildTokenExchangeRequestSubjectOnly(String subjectToken) {
-    HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
-    EasyMock.expect(request.getRequestURL())
-        .andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
-    EasyMock.expect(request.getParameter(CommonTokenConstants.GRANT_TYPE))
-        .andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
-    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN))
-        .andReturn(subjectToken).anyTimes();
-    // ACTOR_TOKEN not mocked — NiceMock returns null for getParameter(ACTOR_TOKEN)
-    return request;
-  }
-}
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
index 9b7608828..49561ad66 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
@@ -21,9 +21,11 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import org.apache.knox.gateway.security.ActorChainPrincipal;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
 import org.apache.knox.gateway.security.TokenExchangePrincipal;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
 import org.easymock.EasyMock;
 import org.junit.Before;
 import org.junit.Test;
@@ -38,6 +40,7 @@ import java.io.IOException;
 import java.text.ParseException;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * Unit tests for {@link TokenExchangeHandler} covering the RFC 8693 request-validation and
@@ -152,6 +155,22 @@ public class TokenExchangeHandlerTest {
     assertEquals("svc-dataservice", tep.getActorPrincipalName());
   }
 
+  @Test
+  public void testSubjectTokenWithActClaimCreatesActorChainPrincipal() throws Exception {
+    // subject_token already carries a prior delegation chain (an 'act' claim) ...
+    filter.valid.put("subtok", jwtWithActClaim("alice", "KNOXSSO", Map.of("sub", "prior-actor")));
+    filter.valid.put("acttok", jwt("svc-dataservice", "https://k8s"));
+    handler.handle(request("subtok", JWT_TYPE, "acttok", JWT_TYPE), response, chain);
+
+    assertTrue(filter.continued);
+    assertNotNull(filter.establishedSubject);
+    // ... which is preserved as an ActorChainPrincipal in the exchanged Subject
+    final Set<ActorChainPrincipal> actorChainPrincipals =
+        filter.establishedSubject.getPrincipals(ActorChainPrincipal.class);
+    assertFalse("ActorChainPrincipal should be present", actorChainPrincipals.isEmpty());
+    assertEquals("prior-actor", actorChainPrincipals.iterator().next().getCurrentActor());
+  }
+
   @Test
   public void testSubjectValidationFailureDoesNotEstablishContext() throws Exception {
     // "subtok" is not in the valid map -> parseAndValidateJWT returns null (error already sent)
@@ -192,6 +211,16 @@ public class TokenExchangeHandlerTest {
     return jwt;
   }
 
+  private static JWT jwtWithActClaim(String subject, String issuer, Map<String, Object> actClaim) {
+    final JWT jwt = EasyMock.createNiceMock(JWT.class);
+    EasyMock.expect(jwt.getSubject()).andReturn(subject).anyTimes();
+    EasyMock.expect(jwt.getIssuer()).andReturn(issuer).anyTimes();
+    // subject_token carries a prior delegation chain via its 'act' claim
+    EasyMock.expect(jwt.getClaimAsObject(JWTToken.ACT_CLAIM)).andReturn(actClaim).anyTimes();
+    EasyMock.replay(jwt);
+    return jwt;
+  }
+
   /**
    * A {@link JWTFederationFilter} whose validation and context-establishment callbacks are
    * replaced with recording stubs, so the handler's own logic can be exercised in isolation.


(knox) 02/37: KnoxIDF - Fixed pylint and test issues in Docker-based tests

Posted by sm...@apache.org.
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 f980f6ac0e24076f1ecb65fa95cac09d5e5267f7
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Jul 21 15:42:37 2026 +0200

    KnoxIDF - Fixed pylint and test issues in Docker-based tests
---
 .../build/conf/topologies/knoxidf-ldap.xml         |   2 +-
 .github/workflows/tests/common_utils.py            |   4 +-
 .github/workflows/tests/test_knoxidf.py            | 184 +++++++++++----------
 3 files changed, 101 insertions(+), 89 deletions(-)

diff --git a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
index a82920bdb..d25bb8869 100644
--- a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
+++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml
@@ -15,7 +15,7 @@
          </param>
          <param>
             <name>main.ldapRealm.contextFactory.url</name>
-            <value>ldap://ldap:33389</value>
+            <value>ldaps://localhost:33390</value>
          </param>
          <param>
             <name>main.ldapRealm.contextFactory.authenticationMechanism</name>
diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py
index e80193374..5d11de053 100644
--- a/.github/workflows/tests/common_utils.py
+++ b/.github/workflows/tests/common_utils.py
@@ -100,6 +100,6 @@ def get_token_claim(token, claim):
         payload_json = base64.urlsafe_b64decode(payload_b64).decode('utf-8')
         payload = json.loads(payload_json)
         return payload.get(claim)
-    except Exception as e:
+    except (ValueError, IndexError, json.JSONDecodeError) as e:
         print(f"Failed to decode token for claim '{claim}': {e}")
-        return None
\ No newline at end of file
+        return None
diff --git a/.github/workflows/tests/test_knoxidf.py b/.github/workflows/tests/test_knoxidf.py
index 918cc37d2..cbbbacaf2 100644
--- a/.github/workflows/tests/test_knoxidf.py
+++ b/.github/workflows/tests/test_knoxidf.py
@@ -13,18 +13,29 @@
 # 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
+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.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"
@@ -39,7 +50,7 @@ class TestKnoxIDF(unittest.TestCase):
         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"
@@ -54,31 +65,22 @@ class TestKnoxIDF(unittest.TestCase):
         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("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"])
+        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
-        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}")
-        client_id = reg_info["client_id"]
-        client_secret = reg_info["client_secret"]
+        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"
@@ -92,7 +94,7 @@ class TestKnoxIDF(unittest.TestCase):
         # 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}")
+            print(f"Token error response: {response.text}")
         self.assertEqual(response.status_code, 200)
         tokens = response.json()
         self.assertIn("access_token", tokens)
@@ -103,25 +105,9 @@ class TestKnoxIDF(unittest.TestCase):
         Test OIDC Authorization Code Flow with Refresh Token.
         """
         # 1. Register client
-        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}")
-        client_id = reg_info["client_id"]
-        client_secret = reg_info["client_secret"]
+        client_id, client_secret = self._register_test_client()
 
         # 2. Authorize (with Basic Auth for the user 'guest')
-        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize"
         params = {
             "response_type": "code",
             "client_id": client_id,
@@ -130,22 +116,7 @@ class TestKnoxIDF(unittest.TestCase):
             "state": "test_state",
             "auto_consent": "true"
         }
-        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)
-        
-        # Should be a redirect to the callback URL
-        self.assertEqual(response.status_code, 303)
-        location = response.headers.get("Location")
-        self.assertIsNotNone(location)
-        self.assertTrue(location.startswith("http://localhost/callback"))
-        
-        parsed_url = urlparse(location)
-        query_params = parse_qs(parsed_url.query)
-        self.assertIn("code", query_params)
-        self.assertIn("state", query_params)
-        self.assertEqual(query_params["state"][0], "test_state")
-        code = query_params["code"][0]
+        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"
@@ -159,15 +130,14 @@ class TestKnoxIDF(unittest.TestCase):
         }
         response = knox_post(token_url, data=data, verify=False)
         if response.status_code != 200:
-             print(f"Code exchange error: {response.text}")
+            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"]
 
+        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}")
@@ -185,12 +155,12 @@ class TestKnoxIDF(unittest.TestCase):
         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(f"Verifying 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",
@@ -198,7 +168,12 @@ class TestKnoxIDF(unittest.TestCase):
             "client_id": client_id,
             "client_secret": client_secret
         }
-        response = knox_post(token_url, data=data_old, verify=False, headers={"Accept": "application/json"})
+        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)
@@ -214,10 +189,9 @@ class TestKnoxIDF(unittest.TestCase):
 
         # 2. PKCE Setup
         code_verifier = "thisshouldbealongandrandomstringthatissecure"
-        code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().replace('=', '')
+        code_challenge = self._s256_challenge(code_verifier)
 
         # 3. Authorize
-        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize"
         params = {
             "response_type": "code",
             "client_id": client_id,
@@ -228,10 +202,7 @@ class TestKnoxIDF(unittest.TestCase):
             "code_challenge": code_challenge,
             "code_challenge_method": "S256"
         }
-        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
-        self.assertEqual(response.status_code, 303)
-        location = response.headers.get("Location")
-        code = parse_qs(urlparse(location).query)["code"][0]
+        code = self._authorize_get_code(params)
 
         # 4. Token Exchange
         token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
@@ -260,7 +231,6 @@ class TestKnoxIDF(unittest.TestCase):
         code_challenge = code_verifier
 
         # 3. Authorize
-        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize"
         params = {
             "response_type": "code",
             "client_id": client_id,
@@ -271,10 +241,7 @@ class TestKnoxIDF(unittest.TestCase):
             "code_challenge": code_challenge,
             "code_challenge_method": "plain"
         }
-        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
-        self.assertEqual(response.status_code, 303)
-        location = response.headers.get("Location")
-        code = parse_qs(urlparse(location).query)["code"][0]
+        code = self._authorize_get_code(params)
 
         # 4. Token Exchange
         token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token"
@@ -297,37 +264,46 @@ class TestKnoxIDF(unittest.TestCase):
         """
         client_id, client_secret = self._register_test_client()
         code_verifier = "correct-verifier"
-        code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().replace('=', '')
+        code_challenge = self._s256_challenge(code_verifier)
 
         # Authorize
-        auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/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"
+            "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"
         }
-        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
-        code = parse_qs(urlparse(response.headers.get("Location")).query)["code"][0]
+        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"
+            "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
-        response = knox_get(auth_url, params=params, auth=(self.username, self.password), allow_redirects=False)
-        code = parse_qs(urlparse(response.headers.get("Location")).query)["code"][0]
+        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
+            "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)
@@ -335,10 +311,46 @@ class TestKnoxIDF(unittest.TestCase):
 
     def _register_test_client(self):
         reg_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/client/register"
-        data = {"redirect_uris": "http://localhost/callback", "allowed_scopes": "openid,profile,email,offline_access"}
+        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()


(knox) 27/37: KNOX-3414: /userinfo returns 401 invalid_token, not 500, for a bad bearer token (review finding M6)

Posted by sm...@apache.org.
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 478c561810f00a02e08dc40ad878a2e1c3204f37
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:35:34 2026 +0200

    KNOX-3414: /userinfo returns 401 invalid_token, not 500, for a bad bearer token (review finding M6)
    
    getTokenMetadata throws UnknownTokenException for an expired/revoked/unknown
    token, and doGet rethrew it as an unmapped RuntimeException -> HTTP 500. RFC 6750
    requires a protected resource to answer such a token with 401 and a
    WWW-Authenticate: Bearer error="invalid_token" challenge.
    
    Catch UnknownTokenException in getUserInfo and return the RFC 6750 challenge via
    the new invalidToken() helper. Also handle a token that references a
    now-missing federated identity the same way (the token can no longer be
    honored) instead of throwing. doGet no longer wraps-and-rethrows.
    
    Covered by UserInfoResourceInvalidTokenTest.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/UserInfoResource.java  | 40 ++++++---
 .../knoxidf/UserInfoResourceInvalidTokenTest.java  | 94 ++++++++++++++++++++++
 2 files changed, 123 insertions(+), 11 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
index b4fccedd0..f500d6d57 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
@@ -25,7 +25,6 @@ import org.apache.knox.gateway.services.ServiceType;
 import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity;
 import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
-import org.apache.knox.gateway.services.security.token.TokenServiceException;
 import org.apache.knox.gateway.services.security.token.TokenStateService;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.util.JsonUtils;
@@ -60,7 +59,7 @@ public class UserInfoResource {
     private ServletContext servletContext;
 
     @Context
-    private HttpServletRequest request;
+    HttpServletRequest request;
 
     private FederatedIdentityService federatedIdentityService;
 
@@ -72,11 +71,7 @@ public class UserInfoResource {
     }
 
     public Response doGet() {
-        try {
-            return getUserInfo();
-        } catch (UnknownTokenException | TokenServiceException e) {
-            throw new RuntimeException(e);
-        }
+        return getUserInfo();
     }
 
     public Response doPost() {
@@ -85,14 +80,22 @@ public class UserInfoResource {
 
     @GET
     @Produces(MediaType.APPLICATION_JSON)
-    public Response getUserInfo() throws UnknownTokenException, TokenServiceException {
+    public Response getUserInfo() {
         final String tokenId = request.getAttribute(TOKEN_ID_ATTRIBUTE) == null ? null : request.getAttribute(TOKEN_ID_ATTRIBUTE).toString();
         if (tokenId == null) {
             return error("invalid_request", "Cannot find tokenId");
         }
 
         final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString();
-        final TokenMetadata tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId);
+        final TokenMetadata tokenMetadata;
+        try {
+            tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId);
+        } catch (UnknownTokenException e) {
+            // Expired, revoked, or otherwise unknown bearer token. Per RFC 6750 the protected
+            // resource must answer 401 with a WWW-Authenticate: Bearer error="invalid_token"
+            // challenge rather than leaking a 500 for what is a client authentication failure.
+            return invalidToken("The access token is expired, revoked, or unknown");
+        }
         final Map<String, Object> userInfo = new HashMap<>();
 
         // Check if this token has a federated identity
@@ -102,7 +105,12 @@ public class UserInfoResource {
             // Federated user
             final FederatedIdentity federatedIdentity = federatedIdentityService
                     .findById(federatedIdentityId)
-                    .orElseThrow(() -> new TokenServiceException("Federated identity not found"));
+                    .orElse(null);
+            if (federatedIdentity == null) {
+                // The token references a federated identity that no longer exists; the bearer token
+                // can no longer be honored, so answer with the RFC 6750 invalid_token challenge.
+                return invalidToken("The access token references an unknown federated identity");
+            }
 
             // Include only allowed claims
             Map<String, Object> claims = federatedIdentity.getAttributes().entrySet().stream()
@@ -129,10 +137,20 @@ public class UserInfoResource {
         return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build();
     }
 
-    private TokenStateService getReadonlyTokenStateService() {
+    TokenStateService getReadonlyTokenStateService() {
         GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
         return services.getService(ServiceType.TOKEN_STATE_SERVICE);
     }
 
+    /**
+     * Builds the RFC 6750 §3 response for a bad bearer token: HTTP 401 with a
+     * {@code WWW-Authenticate: Bearer error="invalid_token"} challenge and a matching JSON body.
+     */
+    static Response invalidToken(final String description) {
+        final Response base = error("invalid_token", description, Response.Status.UNAUTHORIZED);
+        final String challenge = "Bearer error=\"invalid_token\", error_description=\"" + description + "\"";
+        return Response.fromResponse(base).header("WWW-Authenticate", challenge).build();
+    }
+
 }
 
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/UserInfoResourceInvalidTokenTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/UserInfoResourceInvalidTokenTest.java
new file mode 100644
index 000000000..a2c20bc10
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/UserInfoResourceInvalidTokenTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE_ATTRIBUTE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_ID_ATTRIBUTE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+/**
+ * Verifies /userinfo answers a bad bearer token per RFC 6750 (review finding M6): an expired,
+ * revoked, or unknown token must yield HTTP 401 with a
+ * {@code WWW-Authenticate: Bearer error="invalid_token"} challenge, not a 500 from an unmapped
+ * RuntimeException.
+ */
+public class UserInfoResourceInvalidTokenTest {
+
+  private static final String TOKEN_ID = "11111111-2222-3333-[VISA_CARD_NUMBER_REDACTED]";
+
+  /** Injects the request and a token-state service that throws for an unknown token. */
+  static final class TestableUserInfoResource extends UserInfoResource {
+    private final TokenStateService tss;
+
+    TestableUserInfoResource(final HttpServletRequest req, final TokenStateService tss) {
+      this.request = req;
+      this.tss = tss;
+    }
+
+    @Override
+    TokenStateService getReadonlyTokenStateService() {
+      return tss;
+    }
+  }
+
+  private static HttpServletRequest requestWithToken(final String tokenId) {
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getAttribute(TOKEN_ID_ATTRIBUTE)).andReturn(tokenId).anyTimes();
+    EasyMock.expect(req.getAttribute(SCOPE_ATTRIBUTE)).andReturn(null).anyTimes();
+    EasyMock.replay(req);
+    return req;
+  }
+
+  @Test
+  public void testUnknownTokenYields401WithBearerChallenge() throws Exception {
+    final TokenStateService tss = EasyMock.createNiceMock(TokenStateService.class);
+    EasyMock.expect(tss.getTokenMetadata(TOKEN_ID)).andThrow(new UnknownTokenException(TOKEN_ID)).anyTimes();
+    EasyMock.replay(tss);
+
+    final Response response = new TestableUserInfoResource(requestWithToken(TOKEN_ID), tss).getUserInfo();
+
+    assertEquals("An unknown/expired token must be 401, not 500.",
+        Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
+    final Object challenge = response.getHeaderString("WWW-Authenticate");
+    assertNotNull("RFC 6750 requires a WWW-Authenticate challenge.", challenge);
+    assertTrue("The challenge must be a Bearer invalid_token challenge.",
+        challenge.toString().contains("Bearer") && challenge.toString().contains("invalid_token"));
+    assertTrue("The JSON body should carry the invalid_token error code.",
+        String.valueOf(response.getEntity()).contains("invalid_token"));
+  }
+
+  @Test
+  public void testMissingTokenIdYieldsInvalidRequest() {
+    final TokenStateService tss = EasyMock.createNiceMock(TokenStateService.class);
+    EasyMock.replay(tss);
+
+    final Response response = new TestableUserInfoResource(requestWithToken(null), tss).getUserInfo();
+
+    assertEquals("A missing token id is a client request error (400).",
+        Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+  }
+}


(knox) 36/37: KNOX-3414: fix embedded-Derby table-exists check and add DerbyDB TrustedOidcIssuerService default

Posted by sm...@apache.org.
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 331f3bbd257e5f8b6d2014ba773ac78a21d6c206
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 18:22:31 2026 +0200

    KNOX-3414: fix embedded-Derby table-exists check and add DerbyDB TrustedOidcIssuerService default
    
    JDBCUtils.tableExists passed the table name verbatim for uppercase-storing
    engines (Derby), so a lowercase constant like federated_identity never matched
    Derby's uppercased FEDERATED_IDENTITY metadata. On restart tableExists returned
    false, createTableIfNotExists re-ran the CREATE, and Derby failed with
    "Table/View 'FEDERATED_IDENTITY' already exists". Uppercase-constant callers
    (KNOX_TOKENS, KNOX_PROVIDERS, TRUSTED_OIDC_ISSUERS) accidentally worked. Extract
    a normalizeIdentifier() helper that upper-cases for uppercase-storing engines and
    lower-cases for lowercase-storing ones.
    
    Also add DerbyDBTrustedOidcIssuerService, a self-provisioning embedded-Derby
    default mirroring DerbyDBFederatedIdentityService, and wire
    TrustedOidcIssuerServiceFactory to auto-select Derby vs JDBC based on external
    DB config instead of always using JDBC.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../apache/knox/gateway/database/JDBCUtils.java    |  20 +-
 .../factory/TrustedOidcIssuerServiceFactory.java   |  72 ++++--
 .../DerbyDBTrustedOidcIssuerService.java           | 102 ++++++++
 .../TrustedOidcIssuerServiceFactoryTest.java       | 188 +++++++++++++++
 .../DerbyDBFederatedIdentityServiceTest.java       |  42 ++++
 .../DerbyDBTrustedOidcIssuerServiceTest.java       | 131 ++++++++++
 .../TrustedOidcIssuerServiceFactoryTest.java       | 265 ---------------------
 7 files changed, 540 insertions(+), 280 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java
index 014f8144f..30e2ab59f 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java
@@ -36,7 +36,7 @@ public class JDBCUtils {
         boolean exists;
         try (Connection connection = dataSource.getConnection()) {
             final DatabaseMetaData dbMetadata = connection.getMetaData();
-            final String tableNameToCheck = dbMetadata.storesUpperCaseIdentifiers() ? tableName : tableName.toLowerCase(Locale.ROOT);
+            final String tableNameToCheck = normalizeIdentifier(tableName, dbMetadata);
             try (ResultSet tables = dbMetadata.getTables(connection.getCatalog(), null, tableNameToCheck, null)) {
                 exists = tables.next();
             }
@@ -44,6 +44,24 @@ public class JDBCUtils {
         return exists;
     }
 
+    /**
+     * Normalises an unquoted identifier to the case the driver actually stores it in, so it can be
+     * matched against {@link DatabaseMetaData#getTables}. Derby (and other uppercase-storing
+     * engines) store an unquoted {@code federated_identity} as {@code FEDERATED_IDENTITY}; passing
+     * the name verbatim would match nothing and cause {@code createTableIfNotExists} to re-run the
+     * CREATE and fail with "table already exists". Callers that use already-uppercase constants
+     * (KNOX_TOKENS, KNOX_PROVIDERS, TRUSTED_OIDC_ISSUERS) are unaffected since upper-casing them is
+     * a no-op.
+     */
+    private static String normalizeIdentifier(String identifier, DatabaseMetaData dbMetadata) throws SQLException {
+        if (dbMetadata.storesUpperCaseIdentifiers()) {
+            return identifier.toUpperCase(Locale.ROOT);
+        } else if (dbMetadata.storesLowerCaseIdentifiers()) {
+            return identifier.toLowerCase(Locale.ROOT);
+        }
+        return identifier;
+    }
+
     public static void createTableFromSQL(String createSqlFileName, DataSource dataSource, ClassLoader classLoader) throws Exception {
         try (InputStream is = classLoader.getResourceAsStream(createSqlFileName);
              Connection connection = dataSource.getConnection();Statement createTableStatement = connection.createStatement()) {
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
index 3f520f094..e8219c8e3 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java
@@ -18,11 +18,13 @@ package org.apache.knox.gateway.services.factory;
 
 import org.apache.knox.gateway.GatewayMessages;
 import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.database.DatabaseType;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.services.GatewayServices;
 import org.apache.knox.gateway.services.Service;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.DerbyDBTrustedOidcIssuerService;
 import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService;
 import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService;
 import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
@@ -42,27 +44,20 @@ public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
       throws ServiceLifecycleException {
 
     String implementationToUse = implementation;
+    // No explicit impl configured: auto-select a persistence backend when KnoxIDF is deployed.
+    // Otherwise honor the configured impl (very likely a prod JDBC store).
     if (isEmptyDefaultImplementation(implementationToUse) && isKnoxIdfEnabledInAnyTopology(gatewayServices, gatewayConfig)) {
-      implementationToUse = JdbcTrustedOidcIssuerService.class.getName();
+      implementationToUse = chooseAutoImplementation(gatewayConfig);
     }
 
     TrustedOidcIssuerService service = null;
     if (shouldCreateService(implementationToUse)) {
       if (matchesImplementation(implementationToUse, EmptyTrustedOidcIssuerService.class, true)) {
         service = new EmptyTrustedOidcIssuerService();
+      } else if (matchesImplementation(implementationToUse, DerbyDBTrustedOidcIssuerService.class)) {
+        service = createDerbyService(gatewayServices, gatewayConfig, options);
       } else if (matchesImplementation(implementationToUse, JdbcTrustedOidcIssuerService.class)) {
-        try {
-          final JdbcTrustedOidcIssuerService jdbcService = new JdbcTrustedOidcIssuerService();
-          jdbcService.setAliasService(getAliasService(gatewayServices));
-          jdbcService.init(gatewayConfig, options);
-          service = jdbcService;
-        } catch (ServiceLifecycleException e) {
-          LOG.errorInitializingService(implementationToUse, e.getMessage(), e);
-          service = new EmptyTrustedOidcIssuerService();
-        } catch (Exception e) {
-          throw new ServiceLifecycleException(
-              "Error while creating TrustedOidcIssuerService: " + e, e);
-        }
+        service = createJdbcService(gatewayServices, gatewayConfig, options);
       }
       if (service != null) {
         logServiceUsage(service.getClass().getName(), serviceType);
@@ -71,6 +66,55 @@ public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
     return service;
   }
 
+  /**
+   * Chooses the auto-enabled implementation when KnoxIDF is deployed with no explicit impl: an
+   * operator-configured external database wins (very likely a prod JDBC store), otherwise a
+   * self-provisioning embedded Derby store (the {@code none}/{@code derbydb} default) so the
+   * trusted OIDC issuer registry works out of the box without any extra infrastructure.
+   */
+  String chooseAutoImplementation(GatewayConfig gatewayConfig) {
+    return isExternalDatabaseConfigured(gatewayConfig)
+        ? JdbcTrustedOidcIssuerService.class.getName()
+        : DerbyDBTrustedOidcIssuerService.class.getName();
+  }
+
+  private boolean isExternalDatabaseConfigured(GatewayConfig gatewayConfig) {
+    final String databaseType = gatewayConfig.getDatabaseType();
+    try {
+      return DatabaseType.fromString(databaseType) != DatabaseType.DERBY;
+    } catch (IllegalArgumentException e) {
+      // "none" (the default) or any unrecognized value: no real external DB -> use Derby.
+      return false;
+    }
+  }
+
+  private TrustedOidcIssuerService createDerbyService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map<String, String> options)
+      throws ServiceLifecycleException {
+    try {
+      final DerbyDBTrustedOidcIssuerService derbyService = new DerbyDBTrustedOidcIssuerService();
+      derbyService.setAliasService(getAliasService(gatewayServices));
+      derbyService.setMasterService(getMasterService(gatewayServices));
+      derbyService.init(gatewayConfig, options);
+      return derbyService;
+    } catch (ServiceLifecycleException e) {
+      LOG.errorInitializingService(DerbyDBTrustedOidcIssuerService.class.getName(), e.getMessage(), e);
+      return new EmptyTrustedOidcIssuerService();
+    }
+  }
+
+  private TrustedOidcIssuerService createJdbcService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map<String, String> options)
+      throws ServiceLifecycleException {
+    try {
+      final JdbcTrustedOidcIssuerService jdbcService = new JdbcTrustedOidcIssuerService();
+      jdbcService.setAliasService(getAliasService(gatewayServices));
+      jdbcService.init(gatewayConfig, options);
+      return jdbcService;
+    } catch (ServiceLifecycleException e) {
+      LOG.errorInitializingService(JdbcTrustedOidcIssuerService.class.getName(), e.getMessage(), e);
+      return new EmptyTrustedOidcIssuerService();
+    }
+  }
+
   @Override
   protected ServiceType getServiceType() {
     return ServiceType.TRUSTED_OIDC_ISSUER_SERVICE;
@@ -78,6 +122,6 @@ public class TrustedOidcIssuerServiceFactory extends AbstractServiceFactory {
 
   @Override
   protected Collection<String> getKnownImplementations() {
-    return List.of(DEFAULT_IMPLEMENTATION, JdbcTrustedOidcIssuerService.class.getName());
+    return List.of(DEFAULT_IMPLEMENTATION, JdbcTrustedOidcIssuerService.class.getName(), DerbyDBTrustedOidcIssuerService.class.getName());
   }
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java
new file mode 100644
index 000000000..f1c73e9b3
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_NAME;
+import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_TYPE;
+import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME;
+import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME;
+import static org.apache.knox.gateway.database.DatabaseType.DERBY;
+import static org.apache.knox.gateway.services.security.AliasService.NO_CLUSTER_NAME;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.ServiceLifecycleException;
+import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.gateway.services.token.impl.DerbyDBTokenStateService;
+import org.apache.knox.gateway.shell.jdbc.derby.DerbyDatabase;
+
+/**
+ * A self-provisioning, embedded-Derby backed {@link TrustedOidcIssuerService}. This is the
+ * auto-enabled default when KnoxIDF is deployed without an operator-configured external database,
+ * mirroring how {@link DerbyDBTokenStateService} is the default token-state service and
+ * {@code DerbyDBFederatedIdentityService} is the default federated-identity service.
+ * <p>
+ * It reuses the single embedded Derby database that the token-state service already provisions
+ * under {@code ${securityDir}/tokens} (the {@code ;create=true} JDBC URL is idempotent, so
+ * connecting to an already-booted database simply connects), sets the shared {@link GatewayConfig}
+ * to point at it, ensures the connection user/password aliases exist, and then delegates all
+ * persistence to {@link JdbcTrustedOidcIssuerService} (which builds the
+ * {@link TrustedOidcIssuerDatabase} and self-creates its table).
+ */
+public class DerbyDBTrustedOidcIssuerService extends JdbcTrustedOidcIssuerService {
+
+  private DerbyDatabase derbyDatabase;
+  private Path derbyDatabaseFolder;
+  private MasterService masterService;
+
+  public void setMasterService(MasterService masterService) {
+    this.masterService = masterService;
+  }
+
+  @Override
+  public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
+    try {
+      derbyDatabaseFolder = Paths.get(config.getGatewaySecurityDir(), DerbyDBTokenStateService.DB_NAME);
+      startDerby();
+      ((Configuration) config).set(GATEWAY_DATABASE_TYPE, DERBY.type());
+      ((Configuration) config).set(GATEWAY_DATABASE_NAME, derbyDatabaseFolder.toString());
+      getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_USER_ALIAS_NAME, getDatabaseUserName());
+      getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_PASSWORD_ALIAS_NAME, getDatabasePassword());
+      super.init(config, options);
+    } catch (Exception e) {
+      throw new ServiceLifecycleException("Error while initiating DerbyDBTrustedOidcIssuerService: " + e, e);
+    }
+  }
+
+  private void startDerby() throws Exception {
+    derbyDatabase = new DerbyDatabase(derbyDatabaseFolder.toString());
+    derbyDatabase.create();
+    TimeUnit.SECONDS.sleep(1); // give a bit of time for the server to start
+  }
+
+  private String getDatabasePassword() throws Exception {
+    final char[] dbPasswordAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_PASSWORD_ALIAS_NAME);
+    return dbPasswordAliasValue != null ? new String(dbPasswordAliasValue) : new String(masterService.getMasterSecret());
+  }
+
+  private String getDatabaseUserName() throws Exception {
+    final char[] dbUserAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_USER_ALIAS_NAME);
+    return dbUserAliasValue != null ? new String(dbUserAliasValue) : DerbyDBTokenStateService.DEFAULT_TOKEN_DB_USER_NAME;
+  }
+
+  @Override
+  public void stop() throws ServiceLifecycleException {
+    try {
+      if (derbyDatabase != null) {
+        derbyDatabase.shutdown();
+      }
+    } catch (Exception e) {
+      throw new ServiceLifecycleException("Error while shutting down Derby Database", e);
+    }
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java
new file mode 100644
index 000000000..ff06f7825
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.factory;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.Service;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.DerbyDBTrustedOidcIssuerService;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService;
+import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.gateway.services.topology.TopologyService;
+import org.apache.knox.gateway.topology.Topology;
+import org.apache.knox.test.TestUtils;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Test;
+
+public class TrustedOidcIssuerServiceFactoryTest {
+
+  private final TrustedOidcIssuerServiceFactory serviceFactory = new TrustedOidcIssuerServiceFactory();
+  private final Map<String, String> options = new HashMap<>();
+  private File tempDir;
+  private Service createdService;
+
+  @After
+  public void tearDown() throws Exception {
+    if (createdService != null) {
+      createdService.stop();
+    }
+    if (tempDir != null) {
+      FileUtils.forceDelete(tempDir);
+    }
+  }
+
+  // ------------------------------------------------------------------
+  // Auto-implementation selection
+  // ------------------------------------------------------------------
+
+  @Test
+  public void shouldChooseDerbyWhenNoDatabaseConfigured() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn("none").anyTimes();
+    EasyMock.replay(config);
+    assertEquals(DerbyDBTrustedOidcIssuerService.class.getName(), serviceFactory.chooseAutoImplementation(config));
+  }
+
+  @Test
+  public void shouldChooseDerbyWhenDatabaseTypeIsDerby() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.replay(config);
+    assertEquals(DerbyDBTrustedOidcIssuerService.class.getName(), serviceFactory.chooseAutoImplementation(config));
+  }
+
+  @Test
+  public void shouldChooseJdbcWhenExternalDatabaseConfigured() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.POSTGRESQL.type()).anyTimes();
+    EasyMock.replay(config);
+    assertEquals(JdbcTrustedOidcIssuerService.class.getName(), serviceFactory.chooseAutoImplementation(config));
+  }
+
+  // ------------------------------------------------------------------
+  // Empty (no KNOXIDF) cases
+  // ------------------------------------------------------------------
+
+  /** Zero topologies → KnoxIDF not deployed → Empty. */
+  @Test
+  public void shouldSelectEmptyWhenNoTopologies() throws Exception {
+    final GatewayServices gws = servicesWithTopology(/* no topologies */);
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.replay(config);
+    createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, "");
+    assertTrue(createdService instanceof EmptyTrustedOidcIssuerService);
+  }
+
+  /** Topologies exist but none contain KNOXIDF or KNOXIDF_ADMIN → Empty. */
+  @Test
+  public void shouldSelectEmptyWhenNoKnoxIdfRole() throws Exception {
+    final GatewayServices gws = servicesWithTopology(topologyWithRole("KNOXSSO"));
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.replay(config);
+    createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, "");
+    assertTrue(createdService instanceof EmptyTrustedOidcIssuerService);
+  }
+
+  /** An explicit Empty implementation is honored even when KnoxIDF is deployed. */
+  @Test
+  public void shouldHonorExplicitEmptyImplEvenWhenKnoxIdfIsDeployed() throws Exception {
+    final GatewayServices gws = servicesWithTopology(topologyWithRole("KNOXIDF"));
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.replay(config);
+    createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options,
+        EmptyTrustedOidcIssuerService.class.getName());
+    assertTrue(createdService instanceof EmptyTrustedOidcIssuerService);
+  }
+
+  // ------------------------------------------------------------------
+  // Derby auto-provisioning
+  // ------------------------------------------------------------------
+
+  @Test
+  public void shouldAutoSelectDerbyServiceWhenKnoxIdfDeployedWithoutExternalDatabase() throws Exception {
+    tempDir = TestUtils.createTempDir(this.getClass().getName());
+    final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
+    EasyMock.expect(masterService.getMasterSecret()).andReturn("M4st3RSecret!".toCharArray()).anyTimes();
+    EasyMock.replay(masterService);
+    final AliasService aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.replay(aliasService);
+
+    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
+    EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.singletonList(topologyWithRole("KNOXIDF"))).anyTimes();
+    EasyMock.replay(topologyService);
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes();
+    EasyMock.expect(gws.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes();
+    EasyMock.expect(gws.getService(ServiceType.MASTER_SERVICE)).andReturn(masterService).anyTimes();
+    EasyMock.replay(gws);
+
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(config.getGatewaySecurityDir()).andReturn(tempDir.getAbsolutePath()).anyTimes();
+    EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(tempDir.getAbsolutePath(), "tokens").toString()).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerMaxTrustedIssuers()).andReturn(10).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerDiscoveryCacheTtlSecs()).andReturn(300).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs()).andReturn(2000).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()).andReturn(2000).anyTimes();
+    EasyMock.replay(config);
+
+    createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, "");
+    assertNotNull(createdService);
+    assertTrue("Expected a self-provisioning Derby-backed trusted OIDC issuer service, got "
+        + createdService.getClass().getName(), createdService instanceof DerbyDBTrustedOidcIssuerService);
+  }
+
+  // ------------------------------------------------------------------
+  // Helpers
+  // ------------------------------------------------------------------
+
+  private Topology topologyWithRole(String role) {
+    final Topology topology = new Topology();
+    topology.setName("topology-" + role);
+    final org.apache.knox.gateway.topology.Service service = new org.apache.knox.gateway.topology.Service();
+    service.setRole(role);
+    topology.addService(service);
+    return topology;
+  }
+
+  private GatewayServices servicesWithTopology(Topology... topologies) {
+    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
+    EasyMock.expect(topologyService.getTopologies()).andReturn(Arrays.asList(topologies)).anyTimes();
+    EasyMock.replay(topologyService);
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes();
+    EasyMock.replay(gws);
+    return gws;
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java
index 267dd28a1..2aed54885 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java
@@ -105,4 +105,46 @@ public class DerbyDBFederatedIdentityServiceTest {
         "KEYCLOAK", "https://issuer.example.com/realms/knox", "no-such-subject");
     assertFalse("Did not expect an identity for an unknown subject", missing.isPresent());
   }
+
+  /**
+   * Regression guard for the "Table/View 'FEDERATED_IDENTITY' already exists" failure on restart:
+   * re-initialising against the same on-disk Derby database (as happens on a Knox restart) must not
+   * try to re-create the already-present tables. Before the {@code JDBCUtils.tableExists} casing
+   * fix, the lowercase {@code federated_identity} table name never matched Derby's uppercased
+   * metadata, so init re-ran the CREATE and blew up on the second boot.
+   */
+  @Test
+  public void shouldReinitializeWithoutErrorWhenTablesAlreadyExist() throws Exception {
+    service = newDerbyService();
+    final FederatedIdentity identity = new FederatedIdentity("knox-user-1", "KEYCLOAK", "external-subject-1",
+        "https://issuer.example.com/realms/knox", Instant.now(), new HashMap<>());
+    service.addFederatedIdentity(identity);
+    service.stop();
+
+    // Simulate a restart: a brand-new service instance pointing at the same Derby folder.
+    service = newDerbyService();
+    final Optional<FederatedIdentity> byId = service.findById(identity.getId());
+    assertTrue("Expected the previously-persisted identity to survive a restart", byId.isPresent());
+  }
+
+  private DerbyDBFederatedIdentityService newDerbyService() throws Exception {
+    final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
+    EasyMock.expect(masterService.getMasterSecret()).andReturn("M4st3RSecret!".toCharArray()).anyTimes();
+    EasyMock.replay(masterService);
+
+    final AliasService aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.replay(aliasService);
+
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir.getAbsolutePath()).anyTimes();
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(securityDir.getAbsolutePath(), "tokens").toString()).anyTimes();
+    EasyMock.replay(config);
+
+    final DerbyDBFederatedIdentityService svc = new DerbyDBFederatedIdentityService();
+    svc.setAliasService(aliasService);
+    svc.setMasterService(masterService);
+    svc.init(config, Collections.emptyMap());
+    return svc;
+  }
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java
new file mode 100644
index 000000000..f2bc4f081
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.database.DatabaseType;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.MasterService;
+import org.apache.knox.test.TestUtils;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that {@link DerbyDBTrustedOidcIssuerService} self-provisions an embedded Derby database
+ * and round-trips a trusted OIDC issuer through it.
+ */
+public class DerbyDBTrustedOidcIssuerServiceTest {
+
+  private File securityDir;
+  private DerbyDBTrustedOidcIssuerService service;
+
+  @Before
+  public void setUp() throws IOException {
+    securityDir = TestUtils.createTempDir(this.getClass().getName());
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (service != null) {
+      service.stop();
+    }
+    if (securityDir != null) {
+      FileUtils.forceDelete(securityDir);
+    }
+  }
+
+  @Test
+  public void shouldRoundTripATrustedIssuerOnEmbeddedDerby() throws Exception {
+    service = newService(newConfig());
+
+    final TrustedOidcIssuer issuer = new TrustedOidcIssuer(
+        "https://issuer.example.com/realms/knox", true, "clusterA", Instant.now(), "admin");
+    service.register(issuer);
+
+    assertTrue("Expected the registered issuer to be trusted", service.isTrusted(issuer.getIssuerUrl()));
+    assertTrue("Expected the registered issuer to be dynamic-jwks", service.isDynamicJwks(issuer.getIssuerUrl()));
+    assertFalse("Did not expect an unknown issuer to be trusted", service.isTrusted("https://unknown.example.com"));
+
+    final List<TrustedOidcIssuer> all = service.list();
+    assertEquals(1, all.size());
+    assertEquals(issuer.getIssuerUrl(), all.get(0).getIssuerUrl());
+
+    service.deregister(issuer.getIssuerUrl());
+    assertFalse("Expected the issuer to be gone after deregister", service.isTrusted(issuer.getIssuerUrl()));
+  }
+
+  /**
+   * Regression guard for the "Table/View 'TRUSTED_OIDC_ISSUERS' already exists" failure on restart:
+   * re-initialising against the same on-disk Derby database (as happens on a Knox restart) must not
+   * try to re-create the already-present table.
+   */
+  @Test
+  public void shouldReinitializeWithoutErrorWhenTableAlreadyExists() throws Exception {
+    service = newService(newConfig());
+    final TrustedOidcIssuer issuer = new TrustedOidcIssuer(
+        "https://issuer.example.com/realms/knox", false, null, Instant.now(), "admin");
+    service.register(issuer);
+    service.stop();
+
+    // Simulate a restart: a brand-new service instance pointing at the same Derby folder.
+    service = newService(newConfig());
+    assertTrue("Expected the previously-registered issuer to survive a restart",
+        service.isTrusted(issuer.getIssuerUrl()));
+  }
+
+  private DerbyDBTrustedOidcIssuerService newService(GatewayConfigImpl config) throws Exception {
+    final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
+    EasyMock.expect(masterService.getMasterSecret()).andReturn("M4st3RSecret!".toCharArray()).anyTimes();
+    EasyMock.replay(masterService);
+
+    final AliasService aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.replay(aliasService);
+
+    final DerbyDBTrustedOidcIssuerService svc = new DerbyDBTrustedOidcIssuerService();
+    svc.setAliasService(aliasService);
+    svc.setMasterService(masterService);
+    svc.init(config, Collections.emptyMap());
+    return svc;
+  }
+
+  private GatewayConfigImpl newConfig() {
+    final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class);
+    EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir.getAbsolutePath()).anyTimes();
+    EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(securityDir.getAbsolutePath(), "tokens").toString()).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerMaxTrustedIssuers()).andReturn(10).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerDiscoveryCacheTtlSecs()).andReturn(300).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs()).andReturn(2000).anyTimes();
+    EasyMock.expect(config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()).andReturn(2000).anyTimes();
+    EasyMock.replay(config);
+    return config;
+  }
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
deleted file mode 100644
index be885e1ef..000000000
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java
+++ /dev/null
@@ -1,265 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with this
- * work for additional information regarding copyright ownership. The ASF
- * licenses this file to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- * <p>
- * http://www.apache.org/licenses/LICENSE-2.0
- * <p>
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
- * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
- * License for the specific language governing permissions and limitations under
- * the License.
- */
-package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
-
-import org.apache.knox.gateway.config.GatewayConfig;
-import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
-import org.apache.knox.gateway.database.AbstractDataSourceFactory;
-import org.apache.knox.gateway.database.DatabaseType;
-import org.apache.knox.gateway.services.GatewayServices;
-import org.apache.knox.gateway.services.ServiceType;
-import org.apache.knox.gateway.services.factory.TrustedOidcIssuerServiceFactory;
-import org.apache.knox.gateway.services.security.AliasService;
-import org.apache.knox.gateway.services.topology.TopologyService;
-import org.apache.knox.gateway.topology.Topology;
-import org.easymock.EasyMock;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Test;
-
-import java.sql.DriverManager;
-import java.sql.SQLException;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Map;
-
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
-
-public class TrustedOidcIssuerServiceFactoryTest {
-
-  private static final String DB_NAME = "trustedissuers_factory_test";
-  private static final String DERBY_CREATE_URL = "jdbc:derby:memory:" + DB_NAME + ";create=true";
-  private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" + DB_NAME + ";shutdown=true";
-
-  @BeforeClass
-  public static void setUpClass() throws SQLException {
-    // Derby 10.14 does not recognize locales like en_001; force a standard locale.
-    java.util.Locale.setDefault(java.util.Locale.US);
-    DriverManager.getConnection(DERBY_CREATE_URL).close();
-  }
-
-  @AfterClass
-  public static void tearDownClass() {
-    try {
-      DriverManager.getConnection(DERBY_SHUTDOWN_URL);
-    } catch (SQLException e) {
-      if (!(e.getErrorCode() == 45000 && "08006".equals(e.getSQLState()))) {
-        throw new RuntimeException("Unexpected Derby shutdown error", e);
-      }
-    }
-  }
-
-  // ------------------------------------------------------------------
-  // Empty (no KNOXIDF) cases
-  // ------------------------------------------------------------------
-
-  /** Zero topologies → no topology service returns anything → Empty. */
-  @Test
-  public void testNoTopologiesReturnsEmpty() throws Exception {
-    assertIsEmpty(createFactory(), buildEmptyGatewayServices(), emptyConfig());
-  }
-
-  /** Topologies exist but none contain KNOXIDF or KNOXIDF_ADMIN → Empty. */
-  @Test
-  public void testTopologiesWithNonKnoxIdfRolesReturnsEmpty() throws Exception {
-    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("HDFS", "WEBHDFS"), null);
-    assertIsEmpty(createFactory(), gws, emptyConfig());
-  }
-
-  /** TopologyService is null (not yet registered) → Empty, no NPE. */
-  @Test
-  public void testNullTopologyServiceReturnsEmpty() throws Exception {
-    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
-    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(null).anyTimes();
-    EasyMock.replay(gws);
-    assertIsEmpty(createFactory(), gws, emptyConfig());
-  }
-
-  // ------------------------------------------------------------------
-  // JDBC cases
-  // ------------------------------------------------------------------
-
-  /** A single KNOXIDF topology → JDBC. */
-  @Test
-  public void testKnoxIdfTopologyReturnsJdbc() throws Exception {
-    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("KNOXIDF"), derbyAlias());
-    assertIsJdbc(createFactory(), gws, derbyConfig());
-  }
-
-  /** A single KNOXIDF_ADMIN-only topology (no KNOXIDF) → JDBC. */
-  @Test
-  public void testKnoxIdfAdminOnlyTopologyReturnsJdbc() throws Exception {
-    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("KNOXIDF_ADMIN"), derbyAlias());
-    assertIsJdbc(createFactory(), gws, derbyConfig());
-  }
-
-  /** Both KNOXIDF and KNOXIDF_ADMIN in the same topology → JDBC. */
-  @Test
-  public void testBothRolesInSameTopologyReturnsJdbc() throws Exception {
-    final GatewayServices gws = buildGatewayServicesWithTopology(
-        withRoles("KNOXIDF", "KNOXIDF_ADMIN"), derbyAlias());
-    assertIsJdbc(createFactory(), gws, derbyConfig());
-  }
-
-  /** Multiple topologies; only the second has KNOXIDF → JDBC (verifies the loop continues). */
-  @Test
-  public void testMultipleTopologiesOneHasKnoxIdfReturnsJdbc() throws Exception {
-    final AliasService alias = derbyAlias();
-    final GatewayServices gws = buildGatewayServicesWithMultipleTopologies(
-        withRoles("HDFS", "WEBHDFS"), withRoles("KNOXIDF"), alias);
-    assertIsJdbc(createFactory(), gws, derbyConfig());
-  }
-
-  // ------------------------------------------------------------------
-  // Error handling
-  // ------------------------------------------------------------------
-
-  /**
-   * When JDBC service initialization fails (e.g. bad DB type), the factory must fall back
-   * to EmptyTrustedOidcIssuerService rather than propagating the exception.
-   */
-  @Test
-  public void testJdbcInitFailureFallsBackToEmpty() throws Exception {
-    final GatewayServices gws = buildGatewayServicesWithTopology(withRoles("KNOXIDF"), derbyAlias());
-
-    final GatewayConfig brokenConfig = EasyMock.createNiceMock(GatewayConfig.class);
-    EasyMock.expect(brokenConfig.getDatabaseType()).andReturn("invalid_db_type").anyTimes();
-    EasyMock.expect(brokenConfig.getServiceParameter(EasyMock.anyString(), EasyMock.anyString()))
-        .andReturn("").anyTimes();
-    EasyMock.replay(brokenConfig);
-
-    assertIsEmpty(createFactory(), gws, brokenConfig);
-  }
-
-  // ------------------------------------------------------------------
-  // Helpers
-  // ------------------------------------------------------------------
-
-  private static TrustedOidcIssuerServiceFactory createFactory() {
-    return new TrustedOidcIssuerServiceFactory();
-  }
-
-  private static void assertIsEmpty(TrustedOidcIssuerServiceFactory factory,
-      GatewayServices gws, GatewayConfig config) throws Exception {
-    final org.apache.knox.gateway.services.Service result =
-        factory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, Map.of());
-    assertNotNull(result);
-    assertTrue("Expected EmptyTrustedOidcIssuerService but got " + result.getClass().getSimpleName(),
-        result instanceof EmptyTrustedOidcIssuerService);
-  }
-
-  private static void assertIsJdbc(TrustedOidcIssuerServiceFactory factory,
-      GatewayServices gws, GatewayConfig config) throws Exception {
-    final org.apache.knox.gateway.services.Service result =
-        factory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, Map.of());
-    assertNotNull(result);
-    assertTrue("Expected JdbcTrustedOidcIssuerService but got " + result.getClass().getSimpleName(),
-        result instanceof JdbcTrustedOidcIssuerService);
-  }
-
-  /** GatewayServices with a TopologyService returning no topologies; no AliasService needed. */
-  private static GatewayServices buildEmptyGatewayServices() {
-    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
-    EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.emptyList()).anyTimes();
-    EasyMock.replay(topologyService);
-
-    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
-    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE))
-        .andReturn(topologyService).anyTimes();
-    EasyMock.replay(gws);
-    return gws;
-  }
-
-  /**
-   * Builds a {@link GatewayServices} mock with one topology that has the given service roles.
-   * {@code alias} may be null when no JDBC init will be attempted.
-   */
-  private static GatewayServices buildGatewayServicesWithTopology(
-      String[] roles, AliasService alias) throws Exception {
-    final Topology topology = topologyWithRoles(roles);
-    return buildGatewayServices(Collections.singletonList(topology), alias);
-  }
-
-  private static GatewayServices buildGatewayServicesWithMultipleTopologies(
-      String[] roles1, String[] roles2, AliasService alias) throws Exception {
-    return buildGatewayServices(
-        Arrays.asList(topologyWithRoles(roles1), topologyWithRoles(roles2)), alias);
-  }
-
-  private static GatewayServices buildGatewayServices(
-      java.util.List<Topology> topologies, AliasService alias) throws Exception {
-    final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class);
-    EasyMock.expect(topologyService.getTopologies()).andReturn(topologies).anyTimes();
-    EasyMock.replay(topologyService);
-
-    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
-    EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE))
-        .andReturn(topologyService).anyTimes();
-    if (alias != null) {
-      EasyMock.expect(gws.getService(ServiceType.ALIAS_SERVICE))
-          .andReturn(alias).anyTimes();
-    }
-    EasyMock.replay(gws);
-    return gws;
-  }
-
-  private static Topology topologyWithRoles(String... roles) {
-    final Topology topology = EasyMock.createNiceMock(Topology.class);
-    final java.util.List<org.apache.knox.gateway.topology.Service> services = new java.util.ArrayList<>();
-    for (String role : roles) {
-      final org.apache.knox.gateway.topology.Service svc =
-          EasyMock.createNiceMock(org.apache.knox.gateway.topology.Service.class);
-      EasyMock.expect(svc.getRole()).andReturn(role).anyTimes();
-      EasyMock.replay(svc);
-      services.add(svc);
-    }
-    EasyMock.expect(topology.getServices()).andReturn(services).anyTimes();
-    EasyMock.replay(topology);
-    return topology;
-  }
-
-  private static String[] withRoles(String... roles) {
-    return roles;
-  }
-
-  private static AliasService derbyAlias() throws Exception {
-    final AliasService alias = EasyMock.createNiceMock(AliasService.class);
-    EasyMock.expect(alias.getPasswordFromAliasForGateway(
-        AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
-    EasyMock.expect(alias.getPasswordFromAliasForGateway(
-        AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
-    EasyMock.replay(alias);
-    return alias;
-  }
-
-  private static GatewayConfig derbyConfig() {
-    final GatewayConfigImpl config = new GatewayConfigImpl();
-    config.set(GatewayConfigImpl.GATEWAY_DATABASE_TYPE, DatabaseType.DERBY.type());
-    config.set(GatewayConfigImpl.GATEWAY_DATABASE_NAME, "memory:" + DB_NAME);
-    return config;
-  }
-
-  /** Config mock that returns empty string for getServiceParameter (required for impl detection). */
-  private static GatewayConfig emptyConfig() {
-    final GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
-    EasyMock.expect(config.getServiceParameter(EasyMock.anyString(), EasyMock.anyString()))
-        .andReturn("").anyTimes();
-    EasyMock.replay(config);
-    return config;
-  }
-}


(knox) 11/37: KNOX-3408 - Allow no actor token in JWTFederationFilter.handleTokenExchange (#1339)

Posted by sm...@apache.org.
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 b6f674d42f9da75c3dec492755e3ef00ce57e9b6
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Thu Aug 6 10:50:22 2026 -0700

    KNOX-3408 - Allow no actor token in JWTFederationFilter.handleTokenExchange (#1339)
    
    * KNOX-3408 - Regression tests for subject handling in JWTFederationFilter.handleTokenExchange and
    TokenExchangePrincipal handling in AbstractIdentityAssertionFilter#continueChainAsPrincipal handling
    
    Only unit tests are added for existing functionality.
    
    * KNOX-3408 - Allow no actor token in JWTFederationFilter.handleTokenExchange.
    
    ---------
    
    Co-authored-by: Harrison <hs...@cloudera.com>
---
 ...ctIdentityAssertionFilterTokenExchangeTest.java | 279 +++++++++++++++++++++
 .../federation/jwt/filter/JWTFederationFilter.java |   2 +
 ...JWTFederationFilterHandleTokenExchangeTest.java | 262 +++++++++++++++++++
 3 files changed, 543 insertions(+)

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 000000000..f74ddd55f
--- /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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.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}.
+ *
+ * <p>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.
+ *
+ * <p>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<ImpersonatedPrincipal> 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<PrimaryPrincipal> 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<ImpersonatedPrincipal> 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<PrimaryPrincipal> 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<Map<String, Object>> chain = List.of(Map.of("sub", "prior-actor"));
+    Subject subject = buildSubject(
+        new PrimaryPrincipal("sa-actor"),
+        new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null),
+        new ActorChainPrincipalImpl(chain));
+
+    SubjectCapturingChain capturingChain = runFilterWithSubject(subject);
+
+    Set<ActorChainPrincipal> actorChainPrincipals =
+        capturingChain.subject.getPrincipals(ActorChainPrincipal.class);
+    Assert.assertFalse("ActorChainPrincipal should be preserved", actorChainPrincipals.isEmpty());
+    Assert.assertEquals("getCurrentActor should be prior-actor", "prior-actor",
+        actorChainPrincipals.iterator().next().getCurrentActor());
+  }
+
+  // ---- Helpers ----
+
+  private static Subject buildSubject(java.security.Principal... principals) {
+    Subject s = new Subject();
+    for (java.security.Principal p : principals) {
+      s.getPrincipals().add(p);
+    }
+    return s;
+  }
+
+  /** Runs the filter inside {@code Subject.doAs(subjectToRun, ...)} using the default filter. */
+  private SubjectCapturingChain runFilterWithSubject(Subject subjectToRun) throws Exception {
+    return runFilterWithSubject(subjectToRun, filter);
+  }
+
+  /** Runs the filter inside {@code Subject.doAs(subjectToRun, ...)} using the given filter. */
+  private SubjectCapturingChain runFilterWithSubject(Subject subjectToRun,
+      CommonIdentityAssertionFilter f) throws Exception {
+    SubjectCapturingChain chain = new SubjectCapturingChain();
+    HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+    Subject.doAs(subjectToRun, (PrivilegedExceptionAction<Void>) () -> {
+      f.doFilter(request, response, chain);
+      return null;
+    });
+    return chain;
+  }
+
+  private static class SubjectCapturingChain implements FilterChain {
+    Subject subject;
+    boolean called;
+
+    @Override
+    public void doFilter(ServletRequest req, ServletResponse resp) {
+      called = true;
+      subject = SubjectUtils.getCurrentSubject();
+    }
+  }
+}
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 30ab50603..f1abb686a 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
@@ -22,6 +22,8 @@ import org.apache.commons.lang3.tuple.Pair;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
new file mode 100644
index 000000000..ca23a5ae7
--- /dev/null
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.provider.federation;
+
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter;
+import org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter;
+import org.apache.knox.gateway.security.ActorChainPrincipal;
+import org.apache.knox.gateway.security.CommonTokenConstants;
+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.TokenExchangePrincipal;
+import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.security.Principal;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import static org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter.JWT_DEFAULT_ISSUER;
+
+/**
+ * Unit tests for the {@link JWTFederationFilter#handleTokenExchange} method (OIDC
+ * delegation path). Each test verifies the Subject constructed.
+ *
+ * <p>These tests use {@link TestJWTFederationFilter} with {@link TestJWTokenAuthority} (static key,
+ * no mocking needed — both tokens use Knox issuer {@code JWT_DEFAULT_ISSUER} which is in the
+ * static expected-issuers list).
+ *
+ * <p>The filter's {@code continueWithEstablishedSecurityContext} runs
+ * {@code Subject.doAs(subject, () -> chain.doFilter(request, response))}. The
+ * {@link AbstractJWTFilterTest.TestFilterChain} captures {@code SubjectUtils.getCurrentSubject()}
+ * from within that doAs context, which is exactly the Subject built. All principal
+ * assertions use {@code chain.subject.getPrincipals(XxxPrincipal.class)}.
+ */
+public class JWTFederationFilterHandleTokenExchangeTest extends AbstractJWTFilterTest {
+
+  static final String ACTOR_ISSUER = "https://actor.oidc.example.com";
+
+  @Before
+  public void setUp() throws Exception {
+    handler = new TestJWTFederationFilter();
+    ((TestJWTFederationFilter) handler).setTokenService(new TestJWTokenAuthority(publicKey));
+    handler.init(new TestFilterConfig(getProperties()));
+  }
+
+  @Override
+  protected void setTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + jwt.serialize()).anyTimes();
+  }
+
+  @Override
+  protected void setGarbledTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + "ljm" + jwt.serialize()).anyTimes();
+  }
+
+  @Override
+  protected String getAudienceProperty() {
+    return JWTFederationFilter.KNOX_TOKEN_AUDIENCES;
+  }
+
+  @Override
+  protected String getVerificationPemProperty() {
+    return JWTFederationFilter.TOKEN_VERIFICATION_PEM;
+  }
+
+  /**
+   * When both subject_token and actor_token are present, the filter establishes the actor
+   * (from actor_token.sub) as the PrimaryPrincipal in the resulting Subject.
+   */
+  @Test
+  public void testActorAndSubjectTokensSetActorAsPrimaryPrincipal() throws Exception {
+    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+    SignedJWT actorJwt = getJWT(JWT_DEFAULT_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = buildTokenExchangeRequest(subjectJwt.serialize(), actorJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    Set<PrimaryPrincipal> principals = chain.subject.getPrincipals(PrimaryPrincipal.class);
+    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, principals.size());
+    Assert.assertEquals("Expected actor as PrimaryPrincipal", "actor-svc",
+        ((Principal) principals.toArray()[0]).getName());
+  }
+
+  /**
+   * When subject_token and actor_token have different issuers, the filter creates a
+   * TokenExchangePrincipal that carries the subject and actor identities with their respective
+   * issuers. Using different issuers ensures that all four TEP fields can be asserted
+   * unambiguously.
+   *
+   * <p>Both issuers are added to the static {@code jwt.expected.issuer} whitelist —
+   * {@code TestJWTokenAuthority} accepts either token because they are signed with
+   * the same test key.
+   */
+  @Test
+  public void testActorAndSubjectTokensCreateTokenExchangePrincipal() throws Exception {
+    Properties props = getProperties();
+    props.setProperty(AbstractJWTFilter.JWT_EXPECTED_ISSUER, JWT_DEFAULT_ISSUER + "," + ACTOR_ISSUER);
+    handler.init(new TestFilterConfig(props));
+
+    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+    SignedJWT actorJwt = getJWT(ACTOR_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = buildTokenExchangeRequest(subjectJwt.serialize(), actorJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    TokenExchangePrincipal tep = SubjectUtils.getTokenExchangePrincipal(chain.subject);
+    Assert.assertNotNull("TokenExchangePrincipal should be present", tep);
+    Assert.assertEquals("Subject principal name", "k8s-sa", tep.getSubjectPrincipalName());
+    Assert.assertEquals("Actor principal name", "actor-svc", tep.getActorPrincipalName());
+    Assert.assertEquals("Subject issuer", JWT_DEFAULT_ISSUER, tep.getSubjectIssuer());
+    Assert.assertEquals("Actor issuer", ACTOR_ISSUER, tep.getActorIssuer());
+  }
+
+  /**
+   * When subject_token carries an {@code act} claim (a prior delegation chain), the filter
+   * extracts it and creates an {@code ActorChainPrincipal} in the resulting Subject so that
+   * the delegation history is preserved through the filter pipeline.
+   */
+  @Test
+  public void testSubjectTokenWithActClaimCreatesActorChainPrincipal() throws Exception {
+    List<Map<String, Object>> actorChainData = List.of(Map.of("sub", "prior-actor"));
+    JWTToken subjectToken = new JWTToken(new JWTokenAttributesBuilder()
+        .setUserName("k8s-sa")
+        .setIssuer(JWT_DEFAULT_ISSUER)
+        .setAlgorithm("RS256")
+        .setExpires(System.currentTimeMillis() + 60000)
+        .setActorChain(actorChainData)
+        .build());
+    subjectToken.sign(new RSASSASigner(privateKey));
+
+    SignedJWT actorJwt = getJWT(JWT_DEFAULT_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = buildTokenExchangeRequest(subjectToken.toString(), actorJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    Set<ActorChainPrincipal> actorChainPrincipals = chain.subject.getPrincipals(ActorChainPrincipal.class);
+    Assert.assertFalse("ActorChainPrincipal should be present", actorChainPrincipals.isEmpty());
+    ActorChainPrincipal acp = actorChainPrincipals.iterator().next();
+    Assert.assertEquals("Expected current actor from act claim", "prior-actor", acp.getCurrentActor());
+  }
+
+  /**
+   * With no actor_token provided, the filter proceeds successfully and the resulting Subject
+   * has the subject itself as PrimaryPrincipal, no TokenExchangePrincipal, and no
+   * ImpersonatedPrincipal.
+   */
+  @Test
+  public void testSubjectTokenOnlySucceeds() throws Exception {
+    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = buildTokenExchangeRequestSubjectOnly(subjectJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    Set<PrimaryPrincipal> principals = chain.subject.getPrincipals(PrimaryPrincipal.class);
+    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, principals.size());
+    Assert.assertEquals("Subject should be its own PrimaryPrincipal", "k8s-sa",
+        ((java.security.Principal) principals.toArray()[0]).getName());
+    Assert.assertNull("No TokenExchangePrincipal expected for subject-only exchange",
+        SubjectUtils.getTokenExchangePrincipal(chain.subject));
+    Assert.assertTrue("ImpersonatedPrincipal set should be empty",
+        chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
+  }
+
+  /**
+   * Builds a token-exchange request mock with both subject_token and actor_token parameters.
+   * The caller must call {@code EasyMock.replay(request)} before using the returned mock.
+   *
+   * @param subjectToken serialized subject JWT
+   * @param actorToken   serialized actor JWT
+   * @return a NiceMock HttpServletRequest configured for a token-exchange grant
+   */
+  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, String actorToken) {
+    HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL())
+        .andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getParameter(CommonTokenConstants.GRANT_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN))
+        .andReturn(subjectToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN))
+        .andReturn(actorToken).anyTimes();
+    return request;
+  }
+
+  /**
+   * Builds a token-exchange request mock with subject_token only. The actor_token parameter
+   * is not mocked, so the NiceMock returns null for {@code getParameter(ACTOR_TOKEN)}.
+   * The caller must call {@code EasyMock.replay(request)} before using the returned mock.
+   *
+   * @param subjectToken serialized subject JWT
+   * @return a NiceMock HttpServletRequest configured for a subject-only token-exchange grant
+   */
+  private HttpServletRequest buildTokenExchangeRequestSubjectOnly(String subjectToken) {
+    HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL())
+        .andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getParameter(CommonTokenConstants.GRANT_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN))
+        .andReturn(subjectToken).anyTimes();
+    // ACTOR_TOKEN not mocked — NiceMock returns null for getParameter(ACTOR_TOKEN)
+    return request;
+  }
+}


(knox) 26/37: KNOX-3414: fix broken double-checked locking in JdbcTrustedOidcIssuerService.init (review finding M5)

Posted by sm...@apache.org.
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 2abbf2a839ac332262d37989e6497c161bc80c10
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:31:41 2026 +0200

    KNOX-3414: fix broken double-checked locking in JdbcTrustedOidcIssuerService.init (review finding M5)
    
    init() tested initialized only at the outer (pre-lock) check and was missing the
    inner re-check under the lock that JdbcFederatedIdentityService has. A startup
    race where two threads both observed initialized==false could therefore both
    enter the critical section sequentially and initialize twice, the second run
    overwriting the already-built database and discoveryHelper references.
    
    Add the inner if (!initialized.get()) recheck inside the lock so a thread that
    blocked while another was initializing becomes a no-op. Mirrors the sibling
    JdbcFederatedIdentityService.
    
    Covered by JdbcTrustedOidcIssuerServiceTest#testConcurrentInitDoesNotReinitialize,
    which deterministically drives the race via the service's own init lock queue and
    asserts the database reference is not rebuilt once initialized.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../JdbcTrustedOidcIssuerService.java              | 36 ++++++++------
 .../JdbcTrustedOidcIssuerServiceTest.java          | 56 ++++++++++++++++++++++
 2 files changed, 77 insertions(+), 15 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
index e72ed56b6..3f345fe57 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
@@ -70,21 +70,27 @@ public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService {
     if (!initialized.get()) {
       initLock.lock();
       try {
-        if (aliasService == null) {
-          throw new ServiceLifecycleException("The required AliasService reference has not been set.");
-        }
-        try {
-          this.maxTrustedIssuers = config.getTrustedOidcIssuerMaxTrustedIssuers();
-          this.database = new TrustedOidcIssuerDatabase(
-              DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
-          this.discoveryHelper = new OIDCDiscoveryHelper(this, config.getTrustedOidcIssuerDiscoveryCacheTtlSecs(),
-              OIDCDiscoveryHelper.buildHttpClient(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs(), config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()));
-          reloadRegistrySnapshot();
-          initialized.set(true);
-        } catch (ServiceLifecycleException e) {
-          throw e;
-        } catch (Exception e) {
-          throw new ServiceLifecycleException("Error initializing JdbcTrustedOidcIssuerService: " + e, e);
+        // Double-checked locking: re-test under the lock so a thread that blocked while another was
+        // initialising does not re-initialise the database/discoveryHelper a second time (mirrors
+        // JdbcFederatedIdentityService). Without this inner check a startup race overwrote the
+        // already-built database and discoveryHelper references.
+        if (!initialized.get()) {
+          if (aliasService == null) {
+            throw new ServiceLifecycleException("The required AliasService reference has not been set.");
+          }
+          try {
+            this.maxTrustedIssuers = config.getTrustedOidcIssuerMaxTrustedIssuers();
+            this.database = new TrustedOidcIssuerDatabase(
+                DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
+            this.discoveryHelper = new OIDCDiscoveryHelper(this, config.getTrustedOidcIssuerDiscoveryCacheTtlSecs(),
+                OIDCDiscoveryHelper.buildHttpClient(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs(), config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()));
+            reloadRegistrySnapshot();
+            initialized.set(true);
+          } catch (ServiceLifecycleException e) {
+            throw e;
+          } catch (Exception e) {
+            throw new ServiceLifecycleException("Error initializing JdbcTrustedOidcIssuerService: " + e, e);
+          }
         }
       } finally {
         initLock.unlock();
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
index e74e476cf..5e8aa1cc8 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
@@ -34,10 +34,13 @@ import java.sql.PreparedStatement;
 import java.sql.SQLException;
 import java.time.Instant;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
@@ -328,6 +331,59 @@ public class JdbcTrustedOidcIssuerServiceTest {
     noAliasService.init(gatewayConfig, null);
   }
 
+  /**
+   * Review finding M5: init() must re-check {@code initialized} inside the lock so a thread that
+   * blocked while another was initialising does not re-initialise (overwriting the already-built
+   * database/discoveryHelper). This deterministically reproduces the race window: the test thread
+   * holds the init lock and marks the service initialised (as a "winning" thread would) while a
+   * second init() call is blocked entering the critical section; when it proceeds, the inner recheck
+   * must make it a no-op and leave the sentinel database reference untouched.
+   */
+  @Test
+  public void testConcurrentInitDoesNotReinitialize() throws Exception {
+    final ReentrantLock initLock = (ReentrantLock) FieldUtils.readField(service, "initLock", true);
+    final AtomicBoolean initialized = (AtomicBoolean) FieldUtils.readField(service, "initialized", true);
+
+    // A sentinel that the losing init() must NOT overwrite if the inner recheck is present.
+    final TrustedOidcIssuerDatabase sentinel = EasyMock.createNiceMock(TrustedOidcIssuerDatabase.class);
+    FieldUtils.writeField(service, "database", sentinel, true);
+
+    // Simulate the pre-init state a second racing thread would have observed at the outer check.
+    initialized.set(false);
+
+    // Hold the lock first, then start the racing init(): it passes the outer !initialized check and
+    // blocks entering the critical section until we release the lock.
+    initLock.lock();
+    final AtomicBoolean workerFailed = new AtomicBoolean(false);
+    final Thread worker = new Thread(() -> {
+      try {
+        service.init(gatewayConfig, null);
+      } catch (Exception e) {
+        workerFailed.set(true);
+      }
+    });
+    try {
+      worker.start();
+      // Wait until the worker is actually blocked in the lock queue, i.e. it entered init() while
+      // initialized was still false (the exact race the inner recheck must defend against).
+      final long deadline = System.currentTimeMillis() + 10_000;
+      while (!initLock.hasQueuedThreads() && System.currentTimeMillis() < deadline) {
+        Thread.yield();
+      }
+      assertTrue("Worker should be blocked entering the init critical section", initLock.hasQueuedThreads());
+      // Mimic the winning thread finishing initialisation while we hold the lock.
+      initialized.set(true);
+    } finally {
+      initLock.unlock();
+    }
+    worker.join(10_000);
+
+    assertFalse("Racing init() must not have thrown", workerFailed.get());
+    assertFalse("Worker thread must have finished", worker.isAlive());
+    assertSame("A second init() must not rebuild the database once initialised (inner recheck)",
+        sentinel, FieldUtils.readField(service, "database", true));
+  }
+
   // ------------------------------------------------------------------
   // Helpers
   // ------------------------------------------------------------------


(knox) 05/37: KNOX-3390 - Address comments in PR 1315 (#1320)

Posted by sm...@apache.org.
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 f7175b88d9e0e61699a35a5f79c1a3edb19cd4a6
Author: hsheinblatt <hs...@hotmail.com>
AuthorDate: Thu Jul 23 02:48:43 2026 -0700

    KNOX-3390 - Address comments in PR 1315 (#1320)
---
 .../JdbcTrustedOidcIssuerService.java              |  1 +
 .../TrustedOidcIssuerDatabase.java                 | 10 ------
 .../JdbcTrustedOidcIssuerServiceTest.java          | 40 ++++++++++++++++++++++
 pom.xml                                            |  2 +-
 4 files changed, 42 insertions(+), 11 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
index 0b1e0a31e..95246c080 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
@@ -195,6 +195,7 @@ public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService {
       registrySnapshot.set(Collections.unmodifiableMap(fresh));
     } catch (Exception e) {
       LOG.errorReloadingRegistrySnapshot(e.getMessage(), e);
+      throw new RuntimeException("Error reloading trusted OIDC issuer registry snapshot", e);
     }
   }
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
index 92d0fd5c5..05b9d4f72 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java
@@ -44,8 +44,6 @@ class TrustedOidcIssuerDatabase extends KnoxDatabase {
       "DELETE FROM " + TABLE_NAME + " WHERE issuer_url = ?";
   private static final String SELECT_ALL_SQL =
       "SELECT issuer_url, dynamic_jwks, cluster_name, registered_at, registered_by FROM " + TABLE_NAME;
-  private static final String COUNT_SQL =
-      "SELECT COUNT(*) FROM " + TABLE_NAME;
 
   TrustedOidcIssuerDatabase(DataSource dataSource, String dbType) throws Exception {
     super(dataSource);
@@ -90,12 +88,4 @@ class TrustedOidcIssuerDatabase extends KnoxDatabase {
     }
     return result;
   }
-
-  int count() throws SQLException {
-    try (Connection connection = dataSource.getConnection();
-         PreparedStatement ps = connection.prepareStatement(COUNT_SQL);
-         ResultSet rs = ps.executeQuery()) {
-      return rs.next() ? rs.getInt(1) : 0;
-    }
-  }
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
index 03bc54f76..9a7a9673f 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
 
+import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
 import org.apache.knox.gateway.database.AbstractDataSourceFactory;
@@ -279,6 +280,45 @@ public class JdbcTrustedOidcIssuerServiceTest {
     service.refreshJwksUri("https://unknown.example.com"); // must not throw
   }
 
+  // ------------------------------------------------------------------
+  // SQL exception error paths
+  // ------------------------------------------------------------------
+
+  @Test(expected = RuntimeException.class)
+  public void testDeregisterSqlExceptionOnDeleteThrowsRuntimeException() throws Exception {
+    final TrustedOidcIssuerDatabase mockDb = EasyMock.createMock(TrustedOidcIssuerDatabase.class);
+    mockDb.delete(EasyMock.anyString());
+    EasyMock.expectLastCall().andThrow(new java.sql.SQLException("delete failed"));
+    EasyMock.replay(mockDb);
+    FieldUtils.writeField(service, "database", mockDb, true);
+
+    service.deregister("https://any.example.com");
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testRegisterSqlExceptionOnSnapshotReloadPropagates() throws Exception {
+    final TrustedOidcIssuerDatabase mockDb = EasyMock.createMock(TrustedOidcIssuerDatabase.class);
+    mockDb.insert(EasyMock.anyObject(TrustedOidcIssuer.class));
+    EasyMock.expectLastCall();
+    EasyMock.expect(mockDb.selectAll()).andThrow(new java.sql.SQLException("selectAll failed"));
+    EasyMock.replay(mockDb);
+    FieldUtils.writeField(service, "database", mockDb, true);
+
+    service.register(issuer("https://any.example.com", false));
+  }
+
+  @Test(expected = RuntimeException.class)
+  public void testDeregisterSqlExceptionOnSnapshotReloadPropagates() throws Exception {
+    final TrustedOidcIssuerDatabase mockDb = EasyMock.createMock(TrustedOidcIssuerDatabase.class);
+    mockDb.delete(EasyMock.anyString());
+    EasyMock.expectLastCall();
+    EasyMock.expect(mockDb.selectAll()).andThrow(new java.sql.SQLException("selectAll failed"));
+    EasyMock.replay(mockDb);
+    FieldUtils.writeField(service, "database", mockDb, true);
+
+    service.deregister("https://any.example.com");
+  }
+
   // ------------------------------------------------------------------
   // Init guard
   // ------------------------------------------------------------------
diff --git a/pom.xml b/pom.xml
index acc2cd870..a20bb1578 100644
--- a/pom.xml
+++ b/pom.xml
@@ -259,8 +259,8 @@
         <mina.version>2.2.8</mina.version>
         <netty.version>4.1.135.Final</netty.version>
         <nimbus-jose-jwt.version>10.9.1</nimbus-jose-jwt.version>
-        <oauth2-oidc-sdk.version>11.37.2</oauth2-oidc-sdk.version>
         <nodejs.version>v22.20.0</nodejs.version>
+        <oauth2-oidc-sdk.version>11.37.2</oauth2-oidc-sdk.version>
         <okhttp.version>4.12.0</okhttp.version>
         <opensaml.version>5.2.2</opensaml.version>
         <pac4j.version>6.5.3</pac4j.version>


(knox) 30/37: KNOX-3414: emit structured audit records across OAuth2/OIDC endpoints (J2)

Posted by sm...@apache.org.
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 5e7d83f8863234d071d58bf1231c2ba16ea16e36
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 00:17:30 2026 +0200

    KNOX-3414: emit structured audit records across OAuth2/OIDC endpoints (J2)
    
    Add audit-log completeness for the KnoxIDF authorization server. Every
    security-relevant decision on the Authorize, Token, Registration and
    UserInfo endpoints is now recorded through Knox's existing Auditor/
    AuditService framework with a consistent action/outcome/resource shape:
    
    - AuthorizeResource: authorization request rejected, authorization code
      issued / issuance failed, consent granted / denied / invalid-state, and
      the federated-OP callback outcome (single try/finally over all exits).
    - TokenResource: authorization_code and refresh_token grant outcomes
      (issued/rotated vs. replayed/validation-failed) and unsupported grant.
    - RegistrationResource: client-registration outcome incl. anonymous-denied
      and invalid_request reasons.
    - UserInfoResource: /userinfo access outcome incl. invalid_token and
      unknown-federated-identity reasons.
    
    Emission is centralized in a new KnoxIDFAudit holder so all records share
    one Auditor, a single masking rule and a consistent shape. Credentials and
    full tokens are NEVER logged: token ids/JWTs pass through mask() (prefix/
    suffix only) and client_secret/code_verifier/raw refresh tokens are never
    recorded. The Auditor field is package-private/non-final for test injection,
    mirroring TrustedOidcIssuersResource.
    
    Covered by KnoxIDFAuditTest: a representative SUCCESS (rotated refresh
    grant) and FAILURE (unsupported grant) assert the emitted record, plus the
    mask() never-leaks invariant. Full knoxidf suite: 88/88 green.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/AuthorizeResource.java | 140 +++++++----
 .../knox/gateway/service/knoxidf/KnoxIDFAudit.java |  91 +++++++
 .../service/knoxidf/RegistrationResource.java      |  59 +++--
 .../gateway/service/knoxidf/TokenResource.java     |  77 ++++--
 .../gateway/service/knoxidf/UserInfoResource.java  | 113 +++++----
 .../gateway/service/knoxidf/KnoxIDFAuditTest.java  | 274 +++++++++++++++++++++
 6 files changed, 622 insertions(+), 132 deletions(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index 84c213c95..d2a12b581 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -33,6 +33,9 @@ import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.impl.client.HttpClients;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.ResourceType;
 import org.apache.knox.gateway.security.SubjectUtils;
 import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase;
 import org.apache.knox.gateway.services.GatewayServices;
@@ -182,6 +185,11 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         final AuthorizeRequestMetadata authorizeRequestMetadata = new AuthorizeRequestMetadata(clientId, subject, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod);
         final Response verificationErrorResponse = verifyParams(authorizeRequestMetadata);
         if (verificationErrorResponse != null) {
+            // The authorization request was rejected (unknown client_id, bad redirect_uri, disallowed
+            // scope, or unsupported PKCE method). Record the rejection with the masked client_id.
+            KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL,
+                    ActionOutcome.FAILURE, "event=authorize subject=" + KnoxIDFAudit.subjectLabel(subject)
+                            + " reason=request_rejected");
             return verificationErrorResponse;
         }
 
@@ -252,13 +260,22 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
     }
 
     private Response getAuthCodeFromKnox(final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair<String, String> federatedTokens) {
+        final String clientId = authorizeRequestMetadata.getClientId();
+        final String subject = KnoxIDFAudit.subjectLabel(authorizeRequestMetadata.getSubject());
         final Response tokenResponse = getAuthenticationToken();
         if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) {
             final Map<String, String> tokenResponseMap = JsonUtils.getMapFromJsonString(tokenResponse.getEntity().toString());
             final String tokenId = tokenResponseMap.get(TOKEN_ID);
             decorateAuthCodeToken(tokenId, authorizeRequestMetadata, federatedTokens);
+            // An authorization code was issued to the client for this subject. The code is masked;
+            // it is a single-use credential and its full value must never appear in the audit log.
+            KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL,
+                    ActionOutcome.SUCCESS, "event=authorize subject=" + subject + " code=" + KnoxIDFAudit.mask(tokenId)
+                            + (federatedTokens == null ? "" : " federated=true") + " reason=code_issued");
             return redirectToAuthSuccess(authorizeRequestMetadata, tokenId);
         }
+        KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL,
+                ActionOutcome.FAILURE, "event=authorize subject=" + subject + " reason=code_issuance_failed");
         return tokenResponse;
     }
 
@@ -293,54 +310,75 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         //This is the callback for the federated OP
         final String federatedAuthCode = request.getParameter(CODE);
         final String state = request.getParameter(STATE);
-        if (StringUtils.isBlank(state) || StringUtils.isBlank(federatedAuthCode)) {
-            return error("invalid_request", "Missing state or code");
-        }
-        final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state);
-        if (authorizeRequestMetadata == null) {
-            return error("invalid_request", "Unknown or expired state");
-        }
-        final Set<FederatedOpConfiguration> opConfigs = federatedOpConfigurationStore.get(state);
-        final FederatedOpConfiguration federatedOpConfiguration = opConfigs == null ? null : opConfigs.stream().findFirst().orElse(null);
-        if (federatedOpConfiguration == null) {
-            return error("invalid_request", "No federated OP configuration available for the request");
-        }
-        // The federated callback state is single-use: invalidate it in both stores now that it has
-        // been validated and captured, so a replayed callback with the same state is rejected. The
-        // nonce Knox sent to the OP was stashed under the same key (the login-session id == state);
-        // retrieve and invalidate it too so it cannot be reused.
-        authorizeRequestMetadataStore.remove(state);
-        federatedOpConfigurationStore.remove(state);
-        final String expectedNonce = federatedNonceStore.get(state);
-        federatedNonceStore.remove(state);
-        final Pair<String, String> federatedTokens;
+        // Audit the federated-OP login callback exactly once. The resource is the federated OP name
+        // (once known); the reason distinguishes the failure modes. The federated auth code and the
+        // OP tokens are never logged.
+        String opName = KnoxIDFAudit.UNKNOWN;
+        String outcome = ActionOutcome.FAILURE;
+        String detail = "reason=unknown";
         try {
-            federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
-        } catch (ClientSecretResolutionException e) {
-            // A configured client-secret alias could not be resolved. This is a server-side
-            // misconfiguration, not a client error, and we deliberately never made the OP call.
-            return error("server_error", e.getMessage());
-        }
-        if (StringUtils.isBlank(federatedTokens.getLeft())) {
-            return error("invalid_request", "Federated OP did not return an id_token");
-        }
-        final JWT federatedIdToken = new JWTToken(federatedTokens.getLeft());
-        // Verify the OP's id_token (signature/issuer/audience/expiry) before trusting any claim in it.
-        final Response validationError = validateFederatedIdToken(federatedIdToken, federatedOpConfiguration);
-        if (validationError != null) {
-            return validationError;
-        }
-        // Bind the (now signature-verified) id_token to this authorization request (OIDC Core 3.1.2.1):
-        // its nonce claim must equal the nonce Knox generated and sent to the OP for this login session.
-        // This is checked only after the token's authenticity is established, so a forged token cannot
-        // supply its own matching nonce. A missing expected nonce (e.g. expired/replayed state) or a
-        // mismatch fails the flow.
-        final Response nonceError = verifyFederatedNonce(expectedNonce, federatedIdToken);
-        if (nonceError != null) {
-            return nonceError;
+            if (StringUtils.isBlank(state) || StringUtils.isBlank(federatedAuthCode)) {
+                detail = "reason=missing_state_or_code";
+                return error("invalid_request", "Missing state or code");
+            }
+            final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state);
+            if (authorizeRequestMetadata == null) {
+                detail = "reason=unknown_state";
+                return error("invalid_request", "Unknown or expired state");
+            }
+            final Set<FederatedOpConfiguration> opConfigs = federatedOpConfigurationStore.get(state);
+            final FederatedOpConfiguration federatedOpConfiguration = opConfigs == null ? null : opConfigs.stream().findFirst().orElse(null);
+            if (federatedOpConfiguration == null) {
+                detail = "reason=no_op_configuration";
+                return error("invalid_request", "No federated OP configuration available for the request");
+            }
+            opName = federatedOpConfiguration.getName();
+            // The federated callback state is single-use: invalidate it in both stores now that it has
+            // been validated and captured, so a replayed callback with the same state is rejected. The
+            // nonce Knox sent to the OP was stashed under the same key (the login-session id == state);
+            // retrieve and invalidate it too so it cannot be reused.
+            authorizeRequestMetadataStore.remove(state);
+            federatedOpConfigurationStore.remove(state);
+            final String expectedNonce = federatedNonceStore.get(state);
+            federatedNonceStore.remove(state);
+            final Pair<String, String> federatedTokens;
+            try {
+                federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration);
+            } catch (ClientSecretResolutionException e) {
+                // A configured client-secret alias could not be resolved. This is a server-side
+                // misconfiguration, not a client error, and we deliberately never made the OP call.
+                detail = "reason=client_secret_unresolved";
+                return error("server_error", e.getMessage());
+            }
+            if (StringUtils.isBlank(federatedTokens.getLeft())) {
+                detail = "reason=no_id_token";
+                return error("invalid_request", "Federated OP did not return an id_token");
+            }
+            final JWT federatedIdToken = new JWTToken(federatedTokens.getLeft());
+            // Verify the OP's id_token (signature/issuer/audience/expiry) before trusting any claim in it.
+            final Response validationError = validateFederatedIdToken(federatedIdToken, federatedOpConfiguration);
+            if (validationError != null) {
+                detail = "reason=id_token_validation_failed";
+                return validationError;
+            }
+            // Bind the (now signature-verified) id_token to this authorization request (OIDC Core 3.1.2.1):
+            // its nonce claim must equal the nonce Knox generated and sent to the OP for this login session.
+            // This is checked only after the token's authenticity is established, so a forged token cannot
+            // supply its own matching nonce. A missing expected nonce (e.g. expired/replayed state) or a
+            // mismatch fails the flow.
+            final Response nonceError = verifyFederatedNonce(expectedNonce, federatedIdToken);
+            if (nonceError != null) {
+                detail = "reason=nonce_mismatch";
+                return nonceError;
+            }
+            final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName());
+            outcome = ActionOutcome.SUCCESS;
+            detail = "reason=federated_identity_resolved subject=" + KnoxIDFAudit.subjectLabel(federatedIdentity.getId());
+            return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight()));
+        } finally {
+            KnoxIDFAudit.audit(Action.AUTHENTICATION, opName, ResourceType.TRUSTED_ISSUER, outcome,
+                    "event=federated_callback op=" + opName + " " + detail);
         }
-        final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName());
-        return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight()));
     }
 
     @GET
@@ -349,11 +387,18 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         final String state = request.getParameter(STATE);
         final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state);
         if (authorizeRequestMetadata == null) {
+            KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.UNKNOWN, ResourceType.PRINCIPAL,
+                    ActionOutcome.FAILURE, "event=consent reason=invalid_or_expired_state");
             return error("invalid_request", "Invalid state");
         }
         // Single-use consent state: invalidate it so the accepted-consent redirect cannot be replayed.
         authorizeRequestMetadataStore.remove(state);
         markConsentAccepted(authorizeRequestMetadata);
+        // Consent was granted by the subject for this client; the ensuing authorize() call audits the
+        // resulting code issuance separately.
+        KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(authorizeRequestMetadata.getClientId()),
+                ResourceType.PRINCIPAL, ActionOutcome.SUCCESS, "event=consent subject="
+                        + KnoxIDFAudit.subjectLabel(authorizeRequestMetadata.getSubject()) + " reason=consent_granted");
         return authorize(authorizeRequestMetadata.getResponseType(),
                 authorizeRequestMetadata.getClientId(),
                 authorizeRequestMetadata.getRedirectUri(),
@@ -367,6 +412,9 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
     @GET
     @Path("/consentDenied")
     public Response consentDenied() throws Exception {
+        KnoxIDFAudit.audit(Action.AUTHORIZATION,
+                KnoxIDFAudit.subjectLabel(SubjectUtils.getCurrentEffectivePrincipalName()), ResourceType.PRINCIPAL,
+                ActionOutcome.FAILURE, "event=consent reason=consent_denied");
         return Response.status(Response.Status.FORBIDDEN).entity("Consent denied!").build();
     }
 
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java
new file mode 100644
index 000000000..62b49cdf2
--- /dev/null
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.audit.api.AuditServiceFactory;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.audit.log4j.audit.AuditConstants;
+import org.apache.knox.gateway.util.Tokens;
+
+/**
+ * Centralized audit emission for the KnoxIDF OAuth2/OIDC endpoints
+ * ({@link AuthorizeResource}, {@link TokenResource}, {@link RegistrationResource},
+ * {@link UserInfoResource}).
+ * <p>
+ * Every security-relevant decision on these endpoints — an authorization request accepted or
+ * rejected, consent shown/granted/denied, an authorization code issued, a code or refresh token
+ * redeemed or replayed, a client registered, a federated callback validated, user info served — is
+ * recorded through this class so the records share a single {@link Auditor} instance, a consistent
+ * action/outcome/resource shape and, crucially, a single masking rule: credentials and full tokens
+ * are NEVER written to the audit log. Token identifiers and JWTs are always passed through
+ * {@link #mask(String)} first, and {@code client_secret}/{@code code_verifier}/raw refresh tokens
+ * are never logged at all.
+ * <p>
+ * The {@link Auditor} field mirrors {@link TrustedOidcIssuersResource}: it is package-private and
+ * non-final so a unit test can inject a capturing mock and assert the emitted record.
+ */
+final class KnoxIDFAudit {
+
+  /** Placeholder used when a resource/subject identifier is absent or cannot be masked. */
+  static final String UNKNOWN = "UNKNOWN";
+
+  /** Placeholder for an unauthenticated caller. */
+  static final String ANONYMOUS = "ANONYMOUS";
+
+  // Non-final and package-private to allow test injection of a mock Auditor (see the sibling
+  // TrustedOidcIssuersResource, which uses the same idiom).
+  static Auditor auditor = AuditServiceFactory.getAuditService()
+      .getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME,
+          AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME);
+
+  private KnoxIDFAudit() {
+  }
+
+  /**
+   * Emits an audit record via the shared {@link Auditor}. The {@code resource} is used verbatim, so
+   * callers that pass a token identifier or JWT MUST first mask it with {@link #mask(String)}. A
+   * blank {@code resource} is normalized to {@link #UNKNOWN} because the underlying auditor rejects a
+   * null resource name.
+   */
+  static void audit(final String action, final String resource, final String resourceType,
+      final String outcome, final String message) {
+    auditor.audit(action, StringUtils.isBlank(resource) ? UNKNOWN : resource, resourceType, outcome, message);
+  }
+
+  /**
+   * Masks a token or token identifier for safe logging. A Knox token UUID is rendered via
+   * {@link Tokens#getTokenIDDisplayText(String)} and a JWT via {@link Tokens#getTokenDisplayText(String)};
+   * both keep only a short prefix/suffix so the full secret never reaches the log. Returns
+   * {@link #UNKNOWN} for a blank or unmaskable value. This method never returns the raw input.
+   */
+  static String mask(final String tokenOrId) {
+    if (StringUtils.isBlank(tokenOrId)) {
+      return UNKNOWN;
+    }
+    String display = Tokens.getTokenIDDisplayText(tokenOrId);
+    if (display == null) {
+      display = Tokens.getTokenDisplayText(tokenOrId);
+    }
+    return display == null ? UNKNOWN : display;
+  }
+
+  /** Renders a subject/principal name for logging, mapping a blank/absent principal to {@link #ANONYMOUS}. */
+  static String subjectLabel(final String subject) {
+    return StringUtils.isBlank(subject) ? ANONYMOUS : subject;
+  }
+}
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 bd7dedff2..6cc02cd37 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java
@@ -18,6 +18,9 @@ package org.apache.knox.gateway.service.knoxidf;
 
 import com.nimbusds.jose.KeyLengthException;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.ResourceType;
 import org.apache.knox.gateway.security.SubjectUtils;
 import org.apache.knox.gateway.service.knoxtoken.ClientCredentialsResource;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
@@ -88,28 +91,46 @@ public class RegistrationResource extends ClientCredentialsResource {
     @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
     public Response registerClient(@FormParam("redirect_uris") String redirectUris,
                                    @FormParam("allowed_scopes") String allowedScopes) {
-        if (anonymousRegistrationDenied()) {
-            return error("access_denied", "Anonymous client registration is disabled. Set '"
-                    + CLIENT_REGISTRATION_ANONYMOUS_ALLOWED + "' to true in the KNOXIDF service configuration to enable it.");
-        }
-        if (StringUtils.isBlank(redirectUris)) {
-            return error("invalid_request", "redirect_uris must be provided");
-        }
-        this.redirectUris = Arrays.asList(redirectUris.split(","));
-        final Response redirectUriVerificationResponse = verifyRedirectUris();
-        if (redirectUriVerificationResponse != null) {
-            return redirectUriVerificationResponse;
-        }
+        // Audit the outcome of every dynamic client-registration attempt exactly once, recording the
+        // caller principal and the reason for a rejection. No secret (the minted client_secret) is
+        // ever logged — only that a client was registered.
+        final String caller = KnoxIDFAudit.subjectLabel(SubjectUtils.getCurrentEffectivePrincipalName());
+        String outcome = ActionOutcome.FAILURE;
+        String detail = "reason=unknown";
+        try {
+            if (anonymousRegistrationDenied()) {
+                detail = "reason=anonymous_denied";
+                return error("access_denied", "Anonymous client registration is disabled. Set '"
+                        + CLIENT_REGISTRATION_ANONYMOUS_ALLOWED + "' to true in the KNOXIDF service configuration to enable it.");
+            }
+            if (StringUtils.isBlank(redirectUris)) {
+                detail = "reason=missing_redirect_uris";
+                return error("invalid_request", "redirect_uris must be provided");
+            }
+            this.redirectUris = Arrays.asList(redirectUris.split(","));
+            final Response redirectUriVerificationResponse = verifyRedirectUris();
+            if (redirectUriVerificationResponse != null) {
+                detail = "reason=invalid_redirect_uris";
+                return redirectUriVerificationResponse;
+            }
 
-        if (StringUtils.isBlank(allowedScopes)) {
-            this.allowedScopes = new ArrayList<>(DEFAULT_SCOPES);
-        } else {
-            this.allowedScopes = Arrays.asList(allowedScopes.split(","));
-            if (!this.allowedScopes.contains("openid")) {
-                return error("invalid_request", "allowed_scopes must include 'openid'");
+            if (StringUtils.isBlank(allowedScopes)) {
+                this.allowedScopes = new ArrayList<>(DEFAULT_SCOPES);
+            } else {
+                this.allowedScopes = Arrays.asList(allowedScopes.split(","));
+                if (!this.allowedScopes.contains("openid")) {
+                    detail = "reason=invalid_scope";
+                    return error("invalid_request", "allowed_scopes must include 'openid'");
+                }
             }
+            final Response response = super.doPost();
+            outcome = ActionOutcome.SUCCESS;
+            detail = "reason=client_registered";
+            return response;
+        } finally {
+            KnoxIDFAudit.audit(Action.AUTHENTICATION, caller, ResourceType.PRINCIPAL, outcome,
+                    "event=client_registration " + detail);
         }
-        return super.doPost();
     }
 
     /**
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 c0c71a452..c414f6b1a 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java
@@ -18,6 +18,9 @@ package org.apache.knox.gateway.service.knoxidf;
 
 import com.nimbusds.jose.KeyLengthException;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.ResourceType;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider;
 import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory;
@@ -142,6 +145,9 @@ 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);
     }
 
@@ -207,9 +213,17 @@ public class TokenResource extends PasscodeTokenResourceBase {
     // Package-private for testability (the single-use rotation guard is exercised by
     // TokenResourceRefreshTokenRotationTest); not part of the public resource API.
     Response handleRefreshToken() {
+        // Audit the outcome of every refresh_token grant exactly once. The resource is the masked
+        // client_id; the masked refresh-token id and a reason are recorded in the message. The raw
+        // refresh token and client_secret are never logged.
+        final String clientId = getRequestParam(CLIENT_ID);
+        String maskedRefreshTokenId = KnoxIDFAudit.UNKNOWN;
+        String outcome = ActionOutcome.FAILURE;
+        String detail = "reason=unknown";
         try {
             final String refreshTokenParam = getRequestParam(REFRESH_TOKEN);
             final String refreshTokenId = TokenUtils.getTokenId(refreshTokenParam);
+            maskedRefreshTokenId = KnoxIDFAudit.mask(refreshTokenId);
             final TokenMetadata refreshTokenMetadata = tokenStateService.getTokenMetadata(refreshTokenId);
             validateRefreshTokenGrant(refreshTokenParam, refreshTokenId, refreshTokenMetadata);
 
@@ -221,6 +235,7 @@ public class TokenResource extends PasscodeTokenResourceBase {
             // request already redeemed/rotated this token, so reject it as invalid_grant. This mirrors
             // the consume-before-issue guard on the authorization_code grant (see handleAuthorizationCodeFlow).
             if (!tokenStateService.consumeToken(refreshTokenId)) {
+                detail = "reason=refresh_token_replayed";
                 return error("invalid_grant", "Refresh token has already been redeemed");
             }
 
@@ -233,15 +248,23 @@ public class TokenResource extends PasscodeTokenResourceBase {
             // Build new tokens
             final UserContext userContext = new UserContext(userName, null, userParams);
             final TokenResponseContext resp = getTokenResponse(userContext);
+            outcome = ActionOutcome.SUCCESS;
+            detail = "reason=rotated";
             return resp.build();
         } catch (ParseException e) {
+            detail = "reason=malformed_refresh_token";
             return error("invalid_grant", "Malformed refresh_token");
         } catch (UnknownTokenException e) {
+            detail = "reason=unknown_refresh_token";
             return error("invalid_grant", "Unknown refresh_token");
         } catch (RefreshTokenValidationError e) {
+            detail = "reason=validation_failed";
             return error("invalid_grant", e.getMessage());
+        } finally {
+            KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL,
+                    outcome, "event=token_grant grant_type=refresh_token refresh_token_id="
+                            + maskedRefreshTokenId + " " + detail);
         }
-
     }
 
     // Package-private for testability (client-authentication on the refresh grant is exercised by
@@ -292,27 +315,43 @@ public class TokenResource extends PasscodeTokenResourceBase {
     Response handleAuthorizationCodeFlow() {
         final String code = getRequestParam(CODE);
         final String redirectUri = getRequestParam(REDIRECT_URI);
-
-        final TokenMetadata authCodeMetadata;
+        // Audit the outcome of every authorization_code grant exactly once. The resource is the masked
+        // client_id; the masked auth-code id and a reason are recorded in the message. The raw code,
+        // code_verifier and client_secret are never logged.
+        final String clientId = getRequestParam(CLIENT_ID);
+        String outcome = ActionOutcome.FAILURE;
+        String detail = "reason=unknown";
         try {
-            authCodeMetadata = validateAuthCode(code, redirectUri);
-        } catch (AuthTokenValidationError e) {
-            return error("invalid_grant", e.getMessage());
-        }
+            final TokenMetadata authCodeMetadata;
+            try {
+                authCodeMetadata = validateAuthCode(code, redirectUri);
+            } catch (AuthTokenValidationError e) {
+                detail = "reason=validation_failed";
+                return error("invalid_grant", e.getMessage());
+            }
 
-        // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent
-        // redemptions of the same code, exactly one wins the consume and proceeds; the losers get
-        // invalid_grant. This closes the replay window that existed when the code was only revoked
-        // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT
-        // consumed here, so replaying with bad params cannot burn a victim's still-valid code.
-        if (!tokenStateService.consumeToken(code)) {
-            return error("invalid_grant", "Authorization code has already been redeemed");
-        }
+            // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent
+            // redemptions of the same code, exactly one wins the consume and proceeds; the losers get
+            // invalid_grant. This closes the replay window that existed when the code was only revoked
+            // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT
+            // consumed here, so replaying with bad params cannot burn a victim's still-valid code.
+            if (!tokenStateService.consumeToken(code)) {
+                detail = "reason=code_replayed";
+                return error("invalid_grant", "Authorization code has already been redeemed");
+            }
 
-        // The code is now gone from the store; hand the already-validated metadata to the issuance
-        // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code.
-        request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata);
-        return getAuthenticationToken();
+            // The code is now gone from the store; hand the already-validated metadata to the issuance
+            // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code.
+            request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata);
+            final Response response = getAuthenticationToken();
+            outcome = ActionOutcome.SUCCESS;
+            detail = "reason=tokens_issued";
+            return response;
+        } finally {
+            KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL,
+                    outcome, "event=token_grant grant_type=authorization_code code=" + KnoxIDFAudit.mask(code)
+                            + " " + detail);
+        }
     }
 
     /**
diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
index f500d6d57..320f79785 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java
@@ -18,6 +18,9 @@ package org.apache.knox.gateway.service.knoxidf;
 
 
 import org.apache.commons.lang3.StringUtils;
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.ResourceType;
 import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider;
 import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory;
 import org.apache.knox.gateway.services.GatewayServices;
@@ -82,59 +85,73 @@ public class UserInfoResource {
     @Produces(MediaType.APPLICATION_JSON)
     public Response getUserInfo() {
         final String tokenId = request.getAttribute(TOKEN_ID_ATTRIBUTE) == null ? null : request.getAttribute(TOKEN_ID_ATTRIBUTE).toString();
-        if (tokenId == null) {
-            return error("invalid_request", "Cannot find tokenId");
-        }
-
-        final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString();
-        final TokenMetadata tokenMetadata;
+        // Audit the outcome of every /userinfo access exactly once. The resource is the masked
+        // bearer-token id (never the raw token); the reason distinguishes the failure modes.
+        String outcome = ActionOutcome.FAILURE;
+        String detail = "reason=unknown";
         try {
-            tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId);
-        } catch (UnknownTokenException e) {
-            // Expired, revoked, or otherwise unknown bearer token. Per RFC 6750 the protected
-            // resource must answer 401 with a WWW-Authenticate: Bearer error="invalid_token"
-            // challenge rather than leaking a 500 for what is a client authentication failure.
-            return invalidToken("The access token is expired, revoked, or unknown");
-        }
-        final Map<String, Object> userInfo = new HashMap<>();
-
-        // Check if this token has a federated identity
-        final String federatedIdentityId = tokenMetadata.getMetadata("federated_identity_id");
-
-        if (StringUtils.isNotBlank(federatedIdentityId)) {
-            // Federated user
-            final FederatedIdentity federatedIdentity = federatedIdentityService
-                    .findById(federatedIdentityId)
-                    .orElse(null);
-            if (federatedIdentity == null) {
-                // The token references a federated identity that no longer exists; the bearer token
-                // can no longer be honored, so answer with the RFC 6750 invalid_token challenge.
-                return invalidToken("The access token references an unknown federated identity");
+            if (tokenId == null) {
+                detail = "reason=missing_token_id";
+                return error("invalid_request", "Cannot find tokenId");
             }
 
-            // Include only allowed claims
-            Map<String, Object> claims = federatedIdentity.getAttributes().entrySet().stream()
-                    .filter(e -> AuthorizeResource.ALLOWED_CLAIMS.contains(e.getKey()))
-                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
-
-            // Mandatory claims for OIDC
-            claims.put("sub", federatedIdentity.getUserId()); // internal Knox subject
-            claims.put("idp", federatedIdentity.getProvider());
-
-            // Optional: federated info for auditing
-            claims.put("federated_sub", federatedIdentity.getExternalSubject());
-            claims.put("federated_iss", federatedIdentity.getExternalIssuer());
-
-            // Note: nonce is deliberately NOT returned here. Per OIDC it belongs in the id_token
-            // only; echoing it from the UserInfo endpoint is a spec violation and serves no purpose.
+            final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString();
+            final TokenMetadata tokenMetadata;
+            try {
+                tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId);
+            } catch (UnknownTokenException e) {
+                // Expired, revoked, or otherwise unknown bearer token. Per RFC 6750 the protected
+                // resource must answer 401 with a WWW-Authenticate: Bearer error="invalid_token"
+                // challenge rather than leaking a 500 for what is a client authentication failure.
+                detail = "reason=invalid_token";
+                return invalidToken("The access token is expired, revoked, or unknown");
+            }
+            final Map<String, Object> userInfo = new HashMap<>();
+
+            // Check if this token has a federated identity
+            final String federatedIdentityId = tokenMetadata.getMetadata("federated_identity_id");
+
+            if (StringUtils.isNotBlank(federatedIdentityId)) {
+                // Federated user
+                final FederatedIdentity federatedIdentity = federatedIdentityService
+                        .findById(federatedIdentityId)
+                        .orElse(null);
+                if (federatedIdentity == null) {
+                    // The token references a federated identity that no longer exists; the bearer token
+                    // can no longer be honored, so answer with the RFC 6750 invalid_token challenge.
+                    detail = "reason=unknown_federated_identity";
+                    return invalidToken("The access token references an unknown federated identity");
+                }
+
+                // Include only allowed claims
+                Map<String, Object> claims = federatedIdentity.getAttributes().entrySet().stream()
+                        .filter(e -> AuthorizeResource.ALLOWED_CLAIMS.contains(e.getKey()))
+                        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+                // Mandatory claims for OIDC
+                claims.put("sub", federatedIdentity.getUserId()); // internal Knox subject
+                claims.put("idp", federatedIdentity.getProvider());
+
+                // Optional: federated info for auditing
+                claims.put("federated_sub", federatedIdentity.getExternalSubject());
+                claims.put("federated_iss", federatedIdentity.getExternalIssuer());
+
+                // Note: nonce is deliberately NOT returned here. Per OIDC it belongs in the id_token
+                // only; echoing it from the UserInfo endpoint is a spec violation and serves no purpose.
+
+                userInfo.putAll(claims);
+            } else {
+                // Local Knox user
+                userInfo.putAll(userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope));
+            }
 
-            userInfo.putAll(claims);
-        } else {
-            // Local Knox user
-            userInfo.putAll(userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope));
+            outcome = ActionOutcome.SUCCESS;
+            detail = "reason=served";
+            return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build();
+        } finally {
+            KnoxIDFAudit.audit(Action.ACCESS, KnoxIDFAudit.mask(tokenId), ResourceType.URI, outcome,
+                    "event=userinfo " + detail);
         }
-
-        return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build();
     }
 
     TokenStateService getReadonlyTokenStateService() {
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java
new file mode 100644
index 000000000..f56edbc5e
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java
@@ -0,0 +1,274 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
+import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.audit.api.CorrelationContext;
+import org.apache.knox.gateway.audit.api.AuditContext;
+import org.apache.knox.gateway.audit.api.ResourceType;
+import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider;
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenMetadataType;
+import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.impl.TokenMAC;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Representative coverage for the KnoxIDF audit instrumentation (structured audit-log completeness).
+ * A capturing {@link Auditor} is injected into {@link KnoxIDFAudit#auditor} so the emitted
+ * action/outcome/resource/message can be asserted for a representative SUCCESS path (a rotated
+ * refresh-token grant) and a representative FAILURE path (an unsupported grant type). It also pins
+ * the security-critical invariant that {@link KnoxIDFAudit#mask(String)} never echoes a raw secret
+ * into the record.
+ */
+public class KnoxIDFAuditTest {
+
+  // A UUID so TokenUtils.getTokenId returns it verbatim (no JWT parsing needed).
+  private static final String REFRESH_TOKEN_ID = "11111111-2222-3333-4444-555555555555";
+  private static final String CLIENT = "client-abc";
+  private static final String USER_NAME = "alice";
+  private static final long ISSUE_TIME = 1_700_000_000_000L;
+  private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
+
+  /** Records every thread-local 5-arg audit call so a test can assert what was emitted. */
+  static final class CapturingAuditor implements Auditor {
+    static final class Record {
+      final String action;
+      final String resource;
+      final String resourceType;
+      final String outcome;
+      final String message;
+
+      Record(String action, String resource, String resourceType, String outcome, String message) {
+        this.action = action;
+        this.resource = resource;
+        this.resourceType = resourceType;
+        this.outcome = outcome;
+        this.message = message;
+      }
+    }
+
+    final List<Record> records = new ArrayList<>();
+
+    @Override
+    public void audit(String action, String resourceName, String resourceType, String outcome, String message) {
+      records.add(new Record(action, resourceName, resourceType, outcome, message));
+    }
+
+    @Override
+    public void audit(String action, String resourceName, String resourceType, String outcome) {
+      audit(action, resourceName, resourceType, outcome, null);
+    }
+
+    @Override
+    public void audit(CorrelationContext correlationContext, AuditContext auditContext, String action,
+        String resourceName, String resourceType, String outcome, String message) {
+      audit(action, resourceName, resourceType, outcome, message);
+    }
+
+    @Override
+    public String getServiceName() {
+      return "knox";
+    }
+
+    @Override
+    public String getComponentName() {
+      return "knox";
+    }
+
+    @Override
+    public String getAuditorName() {
+      return "audit";
+    }
+  }
+
+  private static final Auditor ORIGINAL_AUDITOR = KnoxIDFAudit.auditor;
+  private CapturingAuditor capturingAuditor;
+
+  @Before
+  public void setUp() {
+    capturingAuditor = new CapturingAuditor();
+    KnoxIDFAudit.auditor = capturingAuditor;
+  }
+
+  @After
+  public void tearDown() {
+    KnoxIDFAudit.auditor = ORIGINAL_AUDITOR;
+  }
+
+  // ---------------------------------------------------------------------------
+  // mask(): never echoes a raw secret; blank/unmaskable -> UNKNOWN
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testMaskNeverLeaksRawValue() {
+    final String secret = "supersecret-client-secret-value-1234567890";
+    final String masked = KnoxIDFAudit.mask(secret);
+    assertNotNull(masked);
+    assertFalse("mask() must never return the raw secret", secret.equals(masked));
+    assertFalse("mask() output must not contain the full raw secret", masked.contains(secret));
+    assertTrue("mask() output must be shorter than the raw secret", masked.length() < secret.length());
+  }
+
+  @Test
+  public void testMaskBlankOrTooShortIsUnknown() {
+    assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask(null));
+    assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask(""));
+    assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask("   "));
+    // Too short for Tokens display text -> normalized to UNKNOWN, never the raw value.
+    assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask("abc"));
+  }
+
+  // ---------------------------------------------------------------------------
+  // Representative FAILURE: unsupported grant type on the token endpoint
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testUnsupportedGrantTypeEmitsFailureAudit() {
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getParameter(GRANT_TYPE)).andReturn("password").anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.replay(req);
+
+    final TokenResource resource = new TokenResource();
+    resource.request = req;
+
+    final Response response = resource.doPost();
+
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+    assertEquals("Exactly one audit record must be emitted.", 1, capturingAuditor.records.size());
+    final CapturingAuditor.Record record = capturingAuditor.records.get(0);
+    assertEquals(Action.AUTHENTICATION, record.action);
+    assertEquals(ResourceType.PRINCIPAL, record.resourceType);
+    assertEquals(ActionOutcome.FAILURE, record.outcome);
+    assertTrue(record.message.contains("reason=unsupported_grant_type"));
+    assertTrue(record.message.contains("grant_type=password"));
+    // The client_id is masked, never emitted verbatim.
+    assertFalse("client_id must be masked in the audit record", CLIENT.equals(record.resource));
+  }
+
+  // ---------------------------------------------------------------------------
+  // Representative SUCCESS: a rotated refresh-token grant
+  // ---------------------------------------------------------------------------
+
+  /** Field injection plus a stub for the token-mint step so only the audit emission is under test. */
+  private static final class TestableTokenResource extends TokenResource {
+    void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req,
+        final UserParamsProvider userParamsProvider) throws Exception {
+      this.tokenStateService = tss;
+      this.tokenMAC = mac;
+      this.request = req;
+      // userParamsProvider is private on TokenResource and normally wired in init(); inject it
+      // directly so the rotation success path can build its UserContext without a servlet context.
+      final Field field = TokenResource.class.getDeclaredField("userParamsProvider");
+      field.setAccessible(true);
+      field.set(this, userParamsProvider);
+    }
+
+    @Override
+    protected TokenResponseContext getTokenResponse(final UserContext context) {
+      return new TokenResponseContext(null, "issued", Response.ok());
+    }
+  }
+
+  private static String wireSecret(final String tokenId, final String rawPasscode) {
+    final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8))
+        + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8));
+    return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void testRefreshTokenRotationEmitsSuccessAudit() throws Exception {
+    final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray());
+    final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE);
+
+    final TokenMetadata refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.getUserName()).andReturn(USER_NAME).anyTimes();
+    EasyMock.replay(refreshTokenMetadata);
+
+    final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes();
+    EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes();
+    EasyMock.replay(clientMetadata);
+
+    final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class);
+    EasyMock.expect(tokenStateService.getTokenMetadata(REFRESH_TOKEN_ID)).andReturn(refreshTokenMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID))
+        .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes();
+    // This redemption wins the atomic consume and rotates.
+    EasyMock.expect(tokenStateService.consumeToken(REFRESH_TOKEN_ID)).andReturn(true).once();
+    EasyMock.replay(tokenStateService);
+
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getParameter(REFRESH_TOKEN)).andReturn(REFRESH_TOKEN_ID).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes();
+    EasyMock.replay(req);
+
+    final UserParamsProvider userParamsProvider = EasyMock.createNiceMock(UserParamsProvider.class);
+    EasyMock.expect(userParamsProvider.getParamsFor(EasyMock.anyString(), EasyMock.anyObject()))
+        .andReturn(new HashMap<>()).anyTimes();
+    EasyMock.replay(userParamsProvider);
+
+    final TestableTokenResource resource = new TestableTokenResource();
+    resource.inject(tokenStateService, tokenMAC, req, userParamsProvider);
+
+    final Response response = resource.handleRefreshToken();
+
+    assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+    assertEquals("Exactly one audit record must be emitted.", 1, capturingAuditor.records.size());
+    final CapturingAuditor.Record record = capturingAuditor.records.get(0);
+    assertEquals(Action.AUTHENTICATION, record.action);
+    assertEquals(ResourceType.PRINCIPAL, record.resourceType);
+    assertEquals(ActionOutcome.SUCCESS, record.outcome);
+    assertTrue(record.message.contains("grant_type=refresh_token"));
+    assertTrue(record.message.contains("reason=rotated"));
+    // Neither the raw refresh token id nor the client_id appear verbatim.
+    assertFalse(record.message.contains(REFRESH_TOKEN_ID));
+    assertFalse("client_id must be masked in the audit record", CLIENT.equals(record.resource));
+  }
+}


(knox) 23/37: KNOX-3414: reject federated id_token missing sub with 4xx, not 500 (review finding M2)

Posted by sm...@apache.org.
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 8f6860b034a2d4bf5bf1a82c4f215b39239fd044
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:22:23 2026 +0200

    KNOX-3414: reject federated id_token missing sub with 4xx, not 500 (review finding M2)
    
    The federated callback derives the Knox subject and the federated-identity
    primary key from the OP id_token's sub claim, and the identity tables declare
    external_subject NOT NULL. A broken or hostile OP that returns a verified
    id_token with no sub therefore drove a NOT NULL INSERT failure -> HTTP 500 on
    every callback through that OP (a targeted DoS).
    
    sub is REQUIRED by OIDC Core 2. Enforce its presence on the (already
    signature/issuer/audience-verified) id_token via requireFederatedSubject and
    return invalid_request when it is absent/blank, before any persistence happens.
    The iss claim is already covered: the configured expectedIssuer is non-blank, so
    a blank issuer fails the existing issuer-mismatch check.
    
    Covered by AuthorizeResourceFederatedSubjectTest.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/AuthorizeResource.java | 19 +++++-
 .../AuthorizeResourceFederatedSubjectTest.java     | 68 ++++++++++++++++++++++
 2 files changed, 86 insertions(+), 1 deletion(-)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index c2d063096..0ba9ac5bd 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -593,7 +593,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
             return error("invalid_request", "Federated id_token audience mismatch");
         }
 
-        return null;
+        return requireFederatedSubject(idToken);
     }
 
     /**
@@ -615,6 +615,23 @@ public class AuthorizeResource extends PasscodeTokenResourceBase {
         return null;
     }
 
+    /**
+     * Enforces that a verified federated id_token carries the {@code sub} claim, which OIDC Core 2
+     * marks REQUIRED. Knox derives both the Knox subject and the federated-identity primary key from
+     * it, and the identity tables declare {@code external_subject NOT NULL}. A broken or hostile OP
+     * that omits {@code sub} would otherwise drive a NOT NULL insert failure -> HTTP 500 on every
+     * callback through that OP; reject it as a client/OP error instead. Call only after
+     * {@link #validateFederatedIdToken} has established the token's authenticity.
+     *
+     * @return an error {@link Response} when {@code sub} is absent/blank, or {@code null} otherwise.
+     */
+    Response requireFederatedSubject(final JWT idToken) {
+        if (StringUtils.isBlank(idToken.getSubject())) {
+            return error("invalid_request", "Federated id_token is missing the required sub claim");
+        }
+        return null;
+    }
+
     private FederatedIdentity resolveFederatedIdentity(final JWT jwt, String opName) {
         final String issuer = jwt.getIssuer();
         final String subject = jwt.getSubject();
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java
new file mode 100644
index 000000000..890abdc01
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import javax.ws.rs.core.Response;
+
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+/**
+ * Verifies that a federated id_token missing the required {@code sub} claim is rejected as a 4xx
+ * client/OP error rather than surfacing as an HTTP 500 (review finding M2). Knox derives the Knox
+ * subject and the federated-identity primary key from {@code sub}, and the identity tables declare
+ * {@code external_subject NOT NULL}; without this guard a broken or hostile OP omitting {@code sub}
+ * drives a NOT NULL insert failure and a 500 on every callback through that OP.
+ */
+public class AuthorizeResourceFederatedSubjectTest {
+
+  private static JWT idTokenWithSubject(final String subject) {
+    final JWT idToken = EasyMock.createNiceMock(JWT.class);
+    EasyMock.expect(idToken.getSubject()).andReturn(subject).anyTimes();
+    EasyMock.replay(idToken);
+    return idToken;
+  }
+
+  @Test
+  public void testPresentSubjectPasses() {
+    final Response result = new AuthorizeResource().requireFederatedSubject(idTokenWithSubject("user-123"));
+    assertNull("An id_token carrying a sub claim must pass (null == no error).", result);
+  }
+
+  @Test
+  public void testMissingSubjectIsRejectedWith4xx() {
+    final Response result = new AuthorizeResource().requireFederatedSubject(idTokenWithSubject(null));
+    assertNotNull("An id_token without a sub claim must be rejected.", result);
+    assertEquals("A missing sub must be a client/OP error, not a 500.",
+        Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus());
+    assertTrue("The error body should identify the invalid_request condition.",
+        String.valueOf(result.getEntity()).contains("invalid_request"));
+  }
+
+  @Test
+  public void testBlankSubjectIsRejectedWith4xx() {
+    final Response result = new AuthorizeResource().requireFederatedSubject(idTokenWithSubject("   "));
+    assertNotNull("An id_token with a blank sub claim must be rejected.", result);
+    assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus());
+  }
+}


(knox) 20/37: KNOX-3414: authenticate the client on the refresh_token grant (review finding H1)

Posted by sm...@apache.org.
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 6c048894ef90224976d32a9feebec8a639e65aab
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 22:49:35 2026 +0200

    KNOX-3414: authenticate the client on the refresh_token grant (review finding H1)
    
    validateRefreshTokenGrant matched only the client_id carried on the refresh
    token and never proved client identity. Because the JWTFederationFilter Bearer
    path forwards a request to the token endpoint without checking client_secret,
    matching client_id alone would let anyone holding a stolen refresh token redeem
    and rotate it. Add a constant-time client_secret check (isValidClientSecret,
    MAC over tokenId+issueTime+userName+rawPasscode) after the client_id match,
    mirroring the client authentication the authorization_code grant already does.
    
    Covered by TokenResourceRefreshTokenClientAuthTest (valid secret passes;
    missing / wrong / bound-to-a-different-client secret rejected).
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/TokenResource.java     |  17 ++-
 .../TokenResourceRefreshTokenClientAuthTest.java   | 146 +++++++++++++++++++++
 2 files changed, 161 insertions(+), 2 deletions(-)

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 1d0980f56..8c463ab1f 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
@@ -233,7 +233,9 @@ public class TokenResource extends PasscodeTokenResourceBase {
 
     }
 
-    private void validateRefreshTokenGrant(String refreshTokenParam, String refreshTokenId, TokenMetadata refreshTokenMetadata) throws UnknownTokenException, RefreshTokenValidationError {
+    // Package-private for testability (client-authentication on the refresh grant is exercised by
+    // TokenResourceRefreshTokenClientAuthTest); not part of the public resource API.
+    void validateRefreshTokenGrant(String refreshTokenParam, String refreshTokenId, TokenMetadata refreshTokenMetadata) throws UnknownTokenException, RefreshTokenValidationError {
         final String clientId = getRequestParam(CLIENT_ID);
 
         if (StringUtils.isBlank(refreshTokenParam)) {
@@ -262,6 +264,16 @@ public class TokenResource extends PasscodeTokenResourceBase {
         if (!clientId.equals(associatedClientId)) {
             throw new RefreshTokenValidationError("Invalid grant: client_id mismatch");
         }
+
+        // Client authentication (RFC 6749 §6, §10.4). Like the authorization_code grant
+        // (see validateAuthCode), the refresh_token grant must independently prove client identity:
+        // the JWTFederationFilter Bearer path forwards a request to this endpoint without checking
+        // client_secret, so matching client_id alone would let anyone holding a stolen refresh token
+        // redeem and rotate it. KnoxIDF issues every registered client a client_secret, so a
+        // constant-time client_secret check against the stored passcode is required here.
+        if (!isValidClientSecret(clientId, getRequestParam(CLIENT_SECRET))) {
+            throw new RefreshTokenValidationError("Invalid grant: client authentication failed");
+        }
     }
 
     // Package-private for testability (single-use replay guard is exercised by
@@ -547,7 +559,8 @@ public class TokenResource extends PasscodeTokenResourceBase {
         }
     }
 
-    private static class RefreshTokenValidationError extends Exception {
+    // Package-private so TokenResourceRefreshTokenClientAuthTest can assert the specific failure type.
+    static class RefreshTokenValidationError extends Exception {
         RefreshTokenValidationError(String message) {
             super(message);
         }
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java
new file mode 100644
index 000000000..512b5a9d2
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET;
+import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID;
+import static org.junit.Assert.fail;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.knox.gateway.service.knoxidf.TokenResource.RefreshTokenValidationError;
+import org.apache.knox.gateway.services.security.token.TokenMetadata;
+import org.apache.knox.gateway.services.security.token.TokenMetadataType;
+import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.impl.TokenMAC;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that the token endpoint independently authenticates the client on the
+ * {@code refresh_token} grant (review finding H1). Matching only {@code client_id} is not enough:
+ * the JWTFederationFilter Bearer path forwards a request here without checking {@code client_secret},
+ * so a stolen refresh token could otherwise be redeemed (and rotated) by anyone. This mirrors the
+ * client-authentication the authorization_code grant already performs.
+ */
+public class TokenResourceRefreshTokenClientAuthTest {
+
+  private static final String CLIENT = "client-abc";
+  private static final String USER_NAME = "alice";
+  private static final long ISSUE_TIME = 1_700_000_000_000L;
+  private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
+  private static final String REFRESH_TOKEN_ID = "refresh-token-id-123";
+  private static final String REFRESH_TOKEN_PARAM = "the-opaque-refresh-token";
+
+  private TokenMAC tokenMAC;
+  private TokenStateService tokenStateService;
+  private TokenMetadata refreshTokenMetadata;
+
+  /** Exposes injection of the inherited (protected) token-state service, MAC and request. */
+  static final class TestableTokenResource extends TokenResource {
+    void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) {
+      this.tokenStateService = tss;
+      this.tokenMAC = mac;
+      this.request = req;
+    }
+  }
+
+  private static String wireSecret(final String tokenId, final String rawPasscode) {
+    final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8))
+        + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8));
+    return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray());
+    final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE);
+
+    // The refresh token's own metadata: a valid, enabled REFRESH_TOKEN bound to CLIENT.
+    refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes();
+    EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.replay(refreshTokenMetadata);
+
+    // The client's registration metadata, used to authenticate the presented client_secret.
+    final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class);
+    EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes();
+    EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes();
+    EasyMock.replay(clientMetadata);
+
+    tokenStateService = EasyMock.createNiceMock(TokenStateService.class);
+    EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID))
+        .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes();
+    EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes();
+    EasyMock.replay(tokenStateService);
+  }
+
+  private TestableTokenResource resourceWithClientSecret(final String clientSecret) {
+    final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes();
+    EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(clientSecret).anyTimes();
+    EasyMock.replay(req);
+
+    final TestableTokenResource resource = new TestableTokenResource();
+    resource.inject(tokenStateService, tokenMAC, req);
+    return resource;
+  }
+
+  @Test
+  public void testValidClientSecretPassesRefreshGrant() throws Exception {
+    final TestableTokenResource resource = resourceWithClientSecret(wireSecret(CLIENT, RAW_PASSCODE));
+    // Should complete without throwing: a correctly authenticated client may refresh.
+    resource.validateRefreshTokenGrant(REFRESH_TOKEN_PARAM, REFRESH_TOKEN_ID, refreshTokenMetadata);
+  }
+
+  @Test
+  public void testMissingClientSecretIsRejected() {
+    final TestableTokenResource resource = resourceWithClientSecret(null);
+    assertRejected(resource);
+  }
+
+  @Test
+  public void testWrongClientSecretIsRejected() {
+    final TestableTokenResource resource = resourceWithClientSecret(wireSecret(CLIENT, "not-the-real-passcode"));
+    assertRejected(resource);
+  }
+
+  @Test
+  public void testSecretBoundToDifferentClientIsRejected() {
+    // A well-formed secret whose embedded tokenId is not the refreshing client must not pass.
+    final TestableTokenResource resource = resourceWithClientSecret(wireSecret("some-other-client", RAW_PASSCODE));
+    assertRejected(resource);
+  }
+
+  private void assertRejected(final TestableTokenResource resource) {
+    try {
+      resource.validateRefreshTokenGrant(REFRESH_TOKEN_PARAM, REFRESH_TOKEN_ID, refreshTokenMetadata);
+      fail("Refresh grant must reject a request that does not authenticate the client.");
+    } catch (RefreshTokenValidationError expected) {
+      // expected: client authentication failed
+    } catch (Exception e) {
+      fail("Expected RefreshTokenValidationError but got " + e.getClass().getName());
+    }
+  }
+}


(knox) 33/37: KNOX-3414: document the UUIDv5 namespace used for federated subject derivation

Posted by sm...@apache.org.
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 34d403040e77b9003fce8d01a2726637ce297005
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Aug 12 00:48:10 2026 +0200

    KNOX-3414: document the UUIDv5 namespace used for federated subject derivation
    
    The hardcoded KNOX_NAMESPACE UUID is the RFC 4122 "URL" namespace constant,
    not a random value. Add a comment explaining that it is the fixed namespace
    for deriving a stable Knox 'sub' (UUIDv5) from a federated identity's
    issuer+subject, that determinism across logins/restarts is the intent, and
    that it must not change once identities are persisted. Comment only; no
    behavior change.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java   | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
index d2a12b581..4fedca83e 100644
--- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
+++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java
@@ -116,6 +116,11 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error;
 @Path(AuthorizeResource.RESOURCE_PATH)
 public class AuthorizeResource extends PasscodeTokenResourceBase {
     static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/authorize";
+    // RFC 4122 "URL" namespace UUID. Used as the fixed namespace for deriving a STABLE Knox
+    // subject (UUIDv5) from a federated identity's issuer+subject (see deriveKnoxSubject), so the
+    // same upstream user always maps to the same Knox 'sub' across logins and gateway restarts.
+    // Must not change once federated identities are persisted -- it would rewrite every existing
+    // federated user's subject.
     private static final UUID KNOX_NAMESPACE = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
     private static final NameBasedGenerator UUID_V5 = Generators.nameBasedGenerator(KNOX_NAMESPACE);
     public static final Set<String> ALLOWED_CLAIMS = Set.of("preferred_username", "email", "email_verified",


(knox) 12/37: KnoxIDF: remove unused import (caused by the previous master rebase)

Posted by sm...@apache.org.
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 c8ab014f0da05bb690053793a1df31b2cca56d20
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Mon Aug 10 16:25:42 2026 +0200

    KnoxIDF: remove unused import (caused by the previous master rebase)
---
 .../knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java | 1 -
 1 file changed, 1 deletion(-)

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 f1abb686a..59cb219e7 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
@@ -22,7 +22,6 @@ import org.apache.commons.lang3.tuple.Pair;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
-import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
 import org.apache.knox.gateway.services.security.token.TokenUtils;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.services.security.token.impl.JWT;


(knox) 06/37: KNOX-3390: Moved gateway-level config to GatewayConfig and implemented the missing methods (#1322)

Posted by sm...@apache.org.
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 7afb4b48381fc8c879f6396be5f0d7e0cc097bec
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Thu Jul 23 12:12:41 2026 +0200

    KNOX-3390: Moved gateway-level config to GatewayConfig and implemented the missing methods (#1322)
---
 .../gateway/config/impl/GatewayConfigImpl.java     | 20 ++++++++
 .../JdbcTrustedOidcIssuerService.java              | 26 ++---------
 .../JdbcTrustedOidcIssuerServiceTest.java          | 54 ++++++++++------------
 .../org/apache/knox/gateway/GatewayTestConfig.java | 21 +++++++++
 .../apache/knox/gateway/config/GatewayConfig.java  | 21 +++++++++
 5 files changed, 90 insertions(+), 52 deletions(-)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
index d0e34d0fc..f14bc0fe9 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
@@ -1905,4 +1905,24 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig {
   public boolean getGroupUIServicesOnHomepage() {
     return getBoolean(KNOX_HOMEPAGE_GROUP_UI_SERVICES, DEFAULT_GROUP_UI_SERVICES);
   }
+
+  @Override
+  public int getTrustedOidcIssuerMaxTrustedIssuers() {
+    return getInt(TRUSTED_OIDC_ISSUER_MAX_TRUSTED_ISSUERS, TRUSTED_OIDC_ISSUER_MAX_TRUSTED_ISSUERS_DEFAULT);
+  }
+
+  @Override
+  public int getTrustedOidcIssuerDiscoveryCacheTtlSecs() {
+    return getInt(TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS, TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS_DEFAULT);
+  }
+
+  @Override
+  public int getTrustedOidcIssuerDiscoveryConnectTimeoutMs() {
+    return getInt(TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS, TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS_DEFAULT);
+  }
+
+  @Override
+  public int getTrustedOidcIssuerDiscoveryReadTimeoutMs() {
+    return getInt(TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS, TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS_DEFAULT);
+  }
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
index 95246c080..e72ed56b6 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java
@@ -16,13 +16,11 @@
  */
 package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
 
-import org.apache.hadoop.conf.Configuration;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.database.DataSourceProvider;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.AliasService;
-import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
 
 import java.sql.SQLException;
 import java.util.ArrayList;
@@ -55,8 +53,6 @@ public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService {
   private static final TrustedOidcIssuerServiceMessages LOG =
       MessagesFactory.get(TrustedOidcIssuerServiceMessages.class);
 
-  static final String MAX_TRUSTED_ISSUERS_CONFIG = "gateway.trustedoidcissuer.max.issuers";
-  private static final int DEFAULT_MAX_TRUSTED_ISSUERS = 10_000;
 
   private final AtomicBoolean initialized = new AtomicBoolean(false);
   private final Lock initLock = new ReentrantLock(true);
@@ -78,27 +74,11 @@ public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService {
           throw new ServiceLifecycleException("The required AliasService reference has not been set.");
         }
         try {
-          int maxIssuers = DEFAULT_MAX_TRUSTED_ISSUERS;
-          long cacheTtlSecs = KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS;
-          int connectTimeoutMs = KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS;
-          int readTimeoutMs = KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS;
-
-          if (config instanceof Configuration) {
-            final Configuration conf = (Configuration) config;
-            maxIssuers = conf.getInt(MAX_TRUSTED_ISSUERS_CONFIG, DEFAULT_MAX_TRUSTED_ISSUERS);
-            cacheTtlSecs = conf.getLong(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS,
-                KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CACHE_TTL_SECS);
-            connectTimeoutMs = conf.getInt(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS,
-                KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_CONNECT_TIMEOUT_MS);
-            readTimeoutMs = conf.getInt(KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS,
-                KnoxIDFConstants.TRUSTED_OIDC_ISSUER_DEFAULT_DISCOVERY_READ_TIMEOUT_MS);
-          }
-
-          this.maxTrustedIssuers = maxIssuers;
+          this.maxTrustedIssuers = config.getTrustedOidcIssuerMaxTrustedIssuers();
           this.database = new TrustedOidcIssuerDatabase(
               DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType());
-          this.discoveryHelper = new OIDCDiscoveryHelper(this, cacheTtlSecs,
-              OIDCDiscoveryHelper.buildHttpClient(connectTimeoutMs, readTimeoutMs));
+          this.discoveryHelper = new OIDCDiscoveryHelper(this, config.getTrustedOidcIssuerDiscoveryCacheTtlSecs(),
+              OIDCDiscoveryHelper.buildHttpClient(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs(), config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()));
           reloadRegistrySnapshot();
           initialized.set(true);
         } catch (ServiceLifecycleException e) {
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
index 9a7a9673f..bccd9274a 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java
@@ -18,7 +18,6 @@ package org.apache.knox.gateway.services.knoxidf.trustedoidcissuer;
 
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.knox.gateway.config.GatewayConfig;
-import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
 import org.apache.knox.gateway.database.AbstractDataSourceFactory;
 import org.apache.knox.gateway.database.DatabaseType;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
@@ -49,9 +48,8 @@ public class JdbcTrustedOidcIssuerServiceTest {
   private static final String DERBY_URL = "jdbc:derby:memory:" + DB_NAME;
   private static final String DERBY_SHUTDOWN_URL = "jdbc:derby:memory:" + DB_NAME + ";shutdown=true";
 
-  private static GatewayConfig gatewayConfig;
-  private static AliasService aliasService;
-
+  private GatewayConfig gatewayConfig;
+  private AliasService aliasService;
   private JdbcTrustedOidcIssuerService service;
 
   @BeforeClass
@@ -60,18 +58,6 @@ public class JdbcTrustedOidcIssuerServiceTest {
     java.util.Locale.setDefault(java.util.Locale.US);
     // Create the Derby in-memory DB so DerbyDataSourceFactory can connect to it
     DriverManager.getConnection(DERBY_CREATE_URL).close();
-
-    gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class);
-    EasyMock.expect(gatewayConfig.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
-    EasyMock.expect(gatewayConfig.getDatabaseName()).andReturn("memory:" + DB_NAME).anyTimes();
-    EasyMock.replay(gatewayConfig);
-
-    aliasService = EasyMock.createNiceMock(AliasService.class);
-    EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
-        AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
-    EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
-        AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
-    EasyMock.replay(aliasService);
   }
 
   @AfterClass
@@ -87,7 +73,7 @@ public class JdbcTrustedOidcIssuerServiceTest {
   }
 
   @Before
-  public void setUp() throws ServiceLifecycleException, SQLException {
+  public void setUp() throws Exception {
     // Clear table between tests
     try (Connection conn = DriverManager.getConnection(DERBY_URL);
          PreparedStatement ps = conn.prepareStatement("DELETE FROM TRUSTED_OIDC_ISSUERS")) {
@@ -96,6 +82,19 @@ public class JdbcTrustedOidcIssuerServiceTest {
       // Table may not exist yet on first setUp; service.init() will create it
     }
 
+    gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(gatewayConfig.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(gatewayConfig.getDatabaseName()).andReturn("memory:" + DB_NAME).anyTimes();
+    EasyMock.expect(gatewayConfig.getTrustedOidcIssuerMaxTrustedIssuers() ).andReturn(10).anyTimes();
+    EasyMock.replay(gatewayConfig);
+
+    aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
+            AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME)).andReturn(null).anyTimes();
+    EasyMock.expect(aliasService.getPasswordFromAliasForGateway(
+            AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME)).andReturn(null).anyTimes();
+    EasyMock.replay(aliasService);
+
     service = new JdbcTrustedOidcIssuerService();
     service.setAliasService(aliasService);
     service.init(gatewayConfig, null);
@@ -218,12 +217,13 @@ public class JdbcTrustedOidcIssuerServiceTest {
     assertTrue(service.list().isEmpty());
   }
 
-  @Test
+  @Test(expected = IllegalStateException.class)
   public void testMaxTrustedIssuers() throws ServiceLifecycleException {
-    final GatewayConfigImpl limitedConfig = new GatewayConfigImpl();
-    limitedConfig.set(JdbcTrustedOidcIssuerService.MAX_TRUSTED_ISSUERS_CONFIG, "2");
-    limitedConfig.set(GatewayConfigImpl.GATEWAY_DATABASE_TYPE, DatabaseType.DERBY.type());
-    limitedConfig.set(GatewayConfigImpl.GATEWAY_DATABASE_NAME, "memory:" + DB_NAME);
+    final GatewayConfig limitedConfig = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(limitedConfig.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes();
+    EasyMock.expect(limitedConfig.getDatabaseName()).andReturn("memory:" + DB_NAME).anyTimes();
+    EasyMock.expect(limitedConfig.getTrustedOidcIssuerMaxTrustedIssuers() ).andReturn(2).anyTimes();
+    EasyMock.replay(limitedConfig);
 
     final JdbcTrustedOidcIssuerService limitedService = new JdbcTrustedOidcIssuerService();
     limitedService.setAliasService(aliasService);
@@ -235,13 +235,9 @@ public class JdbcTrustedOidcIssuerServiceTest {
     limitedService.register(issuer("https://b.example.com", false));
     assertEquals("Second registration must succeed", 2, limitedService.list().size());
 
-    try {
-      limitedService.register(issuer("https://c.example.com", false));
-      fail("Expected IllegalStateException when exceeding max issuers limit");
-    } catch (IllegalStateException e) {
-      assertEquals("Prior registrations must be unaffected by the rejected call",
-          2, limitedService.list().size());
-    }
+    // this one should fail (see expected error on the test annotation)
+    limitedService.register(issuer("https://c.example.com", false));
+    fail("Expected IllegalStateException when exceeding max issuers limit");
   }
 
   @Test
diff --git a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
index e2dbd08d5..bc648de8c 100644
--- a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
+++ b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
@@ -1369,4 +1369,25 @@ public class GatewayTestConfig extends Configuration implements GatewayConfig {
   public boolean getGroupUIServicesOnHomepage() {
     return false;
   }
+
+  @Override
+  public int getTrustedOidcIssuerMaxTrustedIssuers() {
+    return 0;
+  }
+
+  @Override
+  public int getTrustedOidcIssuerDiscoveryCacheTtlSecs() {
+    return 0;
+  }
+
+  @Override
+  public int getTrustedOidcIssuerDiscoveryConnectTimeoutMs() {
+    return 0;
+  }
+
+  @Override
+  public int getTrustedOidcIssuerDiscoveryReadTimeoutMs() {
+    return 0;
+  }
+
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
index 4aec1fba4..a416d1392 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
@@ -158,6 +158,18 @@ public interface GatewayConfig {
   String LDAP_MAX_SIZE_LIMIT = "gateway.ldap.max.size.limit";
   String LDAP_MAX_TIME_LIMIT = "gateway.ldap.max.time.limit";
 
+  // TrustedOidcIssuerService gateway-level params and their default values
+  String TRUSTED_OIDC_ISSUER_PREFIX = "gateway.trusted.oidc.issuer.";
+  String TRUSTED_OIDC_ISSUER_MAX_TRUSTED_ISSUERS = TRUSTED_OIDC_ISSUER_PREFIX + "max.issuers";
+  int TRUSTED_OIDC_ISSUER_MAX_TRUSTED_ISSUERS_DEFAULT = 10_000;
+  String TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX = TRUSTED_OIDC_ISSUER_PREFIX + "discovery.";
+  String TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS = TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX + "cache.ttl.secs";
+  int TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS_DEFAULT = 600;
+  String TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS = TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX + "connect.timeout.ms";
+  int TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS_DEFAULT = 3000;
+  String TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS = TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX+ "read.timeout.ms";
+  int TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS_DEFAULT = 10000;
+
   /**
    * The location of the gateway configuration.
    * Subdirectories will be: topologies
@@ -1229,4 +1241,13 @@ public interface GatewayConfig {
   Set<String> getPropertyNames();
 
   boolean getGroupUIServicesOnHomepage();
+
+  int getTrustedOidcIssuerMaxTrustedIssuers();
+
+  int getTrustedOidcIssuerDiscoveryCacheTtlSecs();
+
+  int getTrustedOidcIssuerDiscoveryConnectTimeoutMs();
+
+  int getTrustedOidcIssuerDiscoveryReadTimeoutMs();
+
 }


(knox) 29/37: KNOX-3414: advertise only S256 in discovery code_challenge_methods_supported (review finding M8)

Posted by sm...@apache.org.
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 c74d837e0f56d1b0840ac37fa0861bc59c4da828
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Tue Aug 11 23:38:37 2026 +0200

    KNOX-3414: advertise only S256 in discovery code_challenge_methods_supported (review finding M8)
    
    The discovery document listed ["plain","S256"] for
    code_challenge_methods_supported, but AuthorizeResource rejects every
    code_challenge_method other than S256. A client that trusted discovery and used
    plain PKCE was then rejected at /authorize.
    
    Drop "plain" so discovery matches enforcement.
    
    Covered by DiscoveryResourcePkceMethodsTest.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../gateway/service/knoxidf/DiscoveryResource.java |  4 +-
 .../knoxidf/DiscoveryResourcePkceMethodsTest.java  | 52 ++++++++++++++++++++++
 2 files changed, 55 insertions(+), 1 deletion(-)

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 eb6584f3e..e55ea817d 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
@@ -71,7 +71,9 @@ public class DiscoveryResource {
         config.put("grant_types_supported", new String[]{KnoxIDFConstants.AUTH_CODE, KnoxIDFConstants.REFRESH_TOKEN});
         config.put("scopes_supported", KnoxIDFConstants.DEFAULT_SCOPES);
         config.put("id_token_signing_alg_values_supported", new String[]{"RS256"});
-        config.put("code_challenge_methods_supported", new String[]{KnoxIDFConstants.PKCE_METHOD_PLAIN, KnoxIDFConstants.PKCE_METHOD_S256});
+        // Advertise only S256: AuthorizeResource rejects any other code_challenge_method (including
+        // "plain"), so discovery must not claim "plain" support it does not honor.
+        config.put("code_challenge_methods_supported", new String[]{KnoxIDFConstants.PKCE_METHOD_S256});
         return Response.ok(JsonUtils.renderAsJsonString(config)).build();
     }
 
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java
new file mode 100644
index 000000000..dfb1438cf
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.service.knoxidf;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.net.URI;
+
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+/**
+ * Verifies discovery advertises only the PKCE methods it actually honors (review finding M8).
+ * AuthorizeResource rejects any code_challenge_method other than S256, so the discovery document
+ * must not list "plain" -- a client that trusts discovery and sends plain would be rejected at
+ * /authorize.
+ */
+public class DiscoveryResourcePkceMethodsTest {
+
+  @Test
+  public void testCodeChallengeMethodsAdvertisesOnlyS256() {
+    final UriInfo uriInfo = EasyMock.createNiceMock(UriInfo.class);
+    EasyMock.expect(uriInfo.getBaseUri()).andReturn(URI.create("https://knox:8443/gateway/knoxidf/")).anyTimes();
+    EasyMock.replay(uriInfo);
+
+    final Response response = new DiscoveryResource().getConfig(uriInfo);
+    final String body = String.valueOf(response.getEntity());
+
+    assertTrue("Discovery must advertise S256 PKCE support.",
+        body.contains("code_challenge_methods_supported") && body.contains("S256"));
+    assertFalse("Discovery must not advertise 'plain' PKCE, which /authorize rejects.",
+        body.contains("plain"));
+  }
+}