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 <[email protected]>
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 <[email protected]>
---
 .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": "[email protected]",
+      "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 = "[email protected]"
+
+
+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", "[email protected]");
+    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("[email protected]", 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

Reply via email to