From be0c4edca9aa2aacca434368c6373c224ce9bc68 Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Wed, 20 May 2026 22:31:52 +0200 Subject: [PATCH 01/13] 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 +++++++++++++ .../resources/build-tools/spotbugs-filter.xml | 5 + .../applications/knoxauth/app/js/knoxauth.js | 37 +- .../applications/knoxauth/app/login.html | 8 +- .../applications/knoxauth/app/styles/knox.css | 67 +++ .../src/main/resources/docker/Dockerfile | 1 - .../jwt/filter/JWTFederationFilter.java | 20 +- .../jwt/filter/SSOCookieFederationFilter.java | 25 + .../federation/SSOCookieProviderTest.java | 36 +- .../gateway/filter/RedirectToUrlFilter.java | 2 +- .../home/conf/topologies/knoxsso.xml | 4 + gateway-release/home/conf/users.ldif | 20 +- gateway-release/pom.xml | 4 + .../knox/gateway/UrlEncodedFormRequest.java | 14 +- .../database/AbstractDataSourceFactory.java | 8 + .../knox/gateway/database/DatabaseType.java | 39 +- .../knox/gateway/database/KnoxDatabase.java | 36 ++ .../gateway/deploy/DeploymentFactory.java | 7 + .../services/DefaultGatewayServices.java | 2 + .../FederatedIdentityServiceFactory.java | 97 ++++ .../EmptyFederatedIdentitityService.java | 51 ++ .../federation/FederatedIdentityDatabase.java | 132 +++++ .../FederatedIdentityServiceMessages.java | 35 ++ .../JdbcFederatedIdentityService.java | 108 +++++ .../token/impl/TokenStateDatabase.java | 14 +- ...pache.knox.gateway.services.ServiceFactory | 9 +- ...noxIDFFederatedIdentityAttributesTable.sql | 21 + ...FFederatedIdentityAttributesTableDerby.sql | 22 + ...FederatedIdentityAttributesTableOracle.sql | 22 + .../createKnoxIDFFederatedIdentityTable.sql | 25 + ...eateKnoxIDFFederatedIdentityTableDerby.sql | 25 + ...ateKnoxIDFFederatedIdentityTableOracle.sql | 25 + .../services/AbstractGatewayServicesTest.java | 3 +- gateway-service-knoxidf/pom.xml | 115 +++++ .../service/knoxidf/AuthConsentServlet.java | 132 +++++ .../service/knoxidf/AuthorizeResource.java | 408 ++++++++++++++++ .../service/knoxidf/DiscoveryResource.java | 76 +++ .../gateway/service/knoxidf/JwksResource.java | 39 ++ .../gateway/service/knoxidf/OIDCScope.java | 66 +++ .../service/knoxidf/RegistrationResource.java | 153 ++++++ .../service/knoxidf/TokenResource.java | 452 ++++++++++++++++++ .../service/knoxidf/UserInfoResource.java | 141 ++++++ .../KnoxIDFServiceDeploymentContributor.java | 42 ++ .../userparams/EmptyUserParamsProvider.java | 28 ++ .../userparams/LdapUserParamsProvider.java | 201 ++++++++ .../userparams/UserParamsProvider.java | 30 ++ .../userparams/UserParamsProviderFactory.java | 26 + ...ateway.deploy.ServiceDeploymentContributor | 18 + .../service/knoxsso/WebSSOResource.java | 84 ++-- .../knoxtoken/ClientCredentialsResource.java | 6 + .../service/knoxtoken/TokenResource.java | 83 ++-- .../security/CommonTokenConstants.java | 2 + .../knox/gateway/services/ServiceType.java | 3 +- .../knoxidf/federation/FederatedIdentity.java | 87 ++++ .../federation/FederatedIdentityService.java | 34 ++ .../FederatedIdentityServiceException.java | 28 ++ .../security/token/JWTokenAttributes.java | 130 ++--- .../token/JWTokenAttributesBuilder.java | 16 +- .../security/token/TokenMetadata.java | 10 +- .../security/token/TokenMetadataType.java | 2 +- .../services/security/token/TokenUtils.java | 17 + .../services/security/token/impl/JWT.java | 3 + .../security/token/impl/JWTToken.java | 17 + gateway-util-common/pom.xml | 17 + .../apache/knox/gateway/util/JsonUtils.java | 9 +- .../knoxidf/AuthorizeRequestMetadata.java | 124 +++++ .../AuthorizeRequestMetadataStore.java | 33 ++ .../knoxidf/FederatedOpConfiguration.java | 81 ++++ .../FederatedOpConfigurationFactory.java | 42 ++ .../FederatedOpConfigurationStore.java | 35 ++ .../util/knoxidf/KnoxIDFArtifactStore.java | 39 ++ .../util/knoxidf/KnoxIDFConstants.java | 60 +++ .../gateway/util/knoxidf/KnoxIDFUtils.java | 107 +++++ pom.xml | 17 + 80 files changed, 4262 insertions(+), 196 deletions(-) create mode 100644 .github/workflows/build/conf/topologies/knoxidf-ldap.xml create mode 100644 .github/workflows/build/conf/topologies/knoxidf-token.xml create mode 100644 .github/workflows/tests/test_knoxidf.py create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentitityService.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java create mode 100644 gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableOracle.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql create mode 100644 gateway-service-knoxidf/pom.xml create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/OIDCScope.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java create mode 100644 gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor create mode 100644 gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentity.java create mode 100644 gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.java create mode 100644 gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationFactory.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java create mode 100644 gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile index 1781ead94f..5c04076611 100644 --- a/.github/workflows/build/Dockerfile +++ b/.github/workflows/build/Dockerfile @@ -17,7 +17,8 @@ FROM eclipse-temurin:17-jre MAINTAINER moresandeep -RUN useradd -ms /bin/bash gateway +# Install dependencies +RUN apt-get update && apt-get install -y git && useradd -ms /bin/bash gateway # Create temporary directories for extraction RUN mkdir -p /tmp/knox-artifacts /tmp/knoxshell-artifacts /knox-runtime /knoxshell /knox-runtime/knoxshell @@ -43,6 +44,8 @@ ADD .github/workflows/build/conf/topologies/health.xml /knox-runtime/conf/topolo ADD .github/workflows/build/conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml ADD .github/workflows/build/conf/topologies/remoteauth.xml /knox-runtime/conf/topologies/remoteauth.xml ADD .github/workflows/build/conf/topologies/k8sauth.xml /knox-runtime/conf/topologies/k8sauth.xml +ADD .github/workflows/build/conf/topologies/knoxidf-ldap.xml /knox-runtime/conf/topologies/knoxidf-ldap.xml +ADD .github/workflows/build/conf/topologies/knoxidf-token.xml /knox-runtime/conf/topologies/knoxidf-token.xml RUN chown -R gateway /knox-runtime/ diff --git a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml new file mode 100644 index 0000000000..a82920bdbc --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml @@ -0,0 +1,67 @@ + + + + + authentication + ShiroProvider + true + + main.ldapRealm + org.apache.knox.gateway.shirorealm.KnoxLdapRealm + + + main.ldapRealm.userDnTemplate + uid={0},ou=people,dc=hadoop,dc=apache,dc=org + + + main.ldapRealm.contextFactory.url + ldap://ldap:33389 + + + main.ldapRealm.contextFactory.authenticationMechanism + simple + + + urls./knoxidf/api/v1/.well-known/openid-configuration + anon + + + urls./knoxidf/api/v1/client/register + anon + + + urls./knoxidf/api/v1/authorize/callback + anon + + + urls./knoxidf/api/v1/jwks + anon + + + urls./** + authcBasic + + + + identity-assertion + Default + true + + + + + KNOXIDF + + knoxidf.knox.token.ttl + 60000 + + + knoxidf.knox.token.limit.per.user + -1 + + + token.exchange.topology.name + knoxidf-token + + + diff --git a/.github/workflows/build/conf/topologies/knoxidf-token.xml b/.github/workflows/build/conf/topologies/knoxidf-token.xml new file mode 100644 index 0000000000..fdc11d62b8 --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxidf-token.xml @@ -0,0 +1,42 @@ + + + + + federation + JWTProvider + true + + knox.token.exp.server-managed + true + + + + identity-assertion + Default + true + + + + + KNOXIDF + + knoxidf.knox.token.ttl + 86400000 + + + knoxidf.knox.token.limit.per.user + -1 + + + + KNOXTOKEN + + knox.token.ttl + 60000 + + + knox.token.limit.per.user + -1 + + + diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml index 0f584f599c..621830115d 100644 --- a/.github/workflows/publish-test-results.yml +++ b/.github/workflows/publish-test-results.yml @@ -46,5 +46,5 @@ jobs: commit: ${{ github.event.workflow_run.head_sha }} event_file: artifacts/Event File/event.json event_name: ${{ github.event.workflow_run.event }} - files: "artifacts/**/*.xml" + files: "artifacts/test-results/**/*.xml" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da1a7c78fc..9fbd80c413 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -153,6 +153,14 @@ jobs: echo '===== gateway.log =====' cat ./.github/workflows/compose/logs/gateway.log || true + - name: Collect Knox Logs and Conf + if: always() + run: | + mkdir -p .github/workflows/artifacts/knox-logs + mkdir -p .github/workflows/artifacts/knox-conf + docker compose -f ./.github/workflows/compose/docker-compose.yml cp knox:/knox-runtime/logs .github/workflows/artifacts/knox-logs + docker compose -f ./.github/workflows/compose/docker-compose.yml cp knox:/knox-runtime/conf .github/workflows/artifacts/knox-conf + - name: Upload Test Results if: (!cancelled()) uses: actions/upload-artifact@v4 @@ -163,6 +171,28 @@ jobs: .github/workflows/tests/test-results-single-eku.xml .github/workflows/tests/test-results-single-eku-no-mtls.xml + - name: Archive Knox Logs + if: always() + run: tar -cvzf knox-logs.tar.gz -C .github/workflows/artifacts/knox-logs . + + - name: Upload Knox Logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: knox-logs + path: knox-logs.tar.gz + + - name: Archive Knox Conf + if: always() + run: tar -cvzf knox-conf.tar.gz -C .github/workflows/artifacts/knox-conf . + + - name: Upload Knox Conf + if: always() + uses: actions/upload-artifact@v4 + with: + name: knox-conf + path: knox-conf.tar.gz + - name: Upload Event File uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py index 0a773b44e6..e801933749 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 0000000000..918cc37d25 --- /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 fb4d7857d4..d27630435e 100644 --- a/build-tools/src/main/resources/build-tools/spotbugs-filter.xml +++ b/build-tools/src/main/resources/build-tools/spotbugs-filter.xml @@ -85,4 +85,9 @@ limitations under the License. + + + + + diff --git a/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js b/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js index 4f91c2fca2..5a304a5565 100644 --- a/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js +++ b/gateway-applications/src/main/resources/applications/knoxauth/app/js/knoxauth.js @@ -16,7 +16,8 @@ */ var loginPageSuffix = "/knoxauth/login.html"; -var webssoURL = "/api/v1/websso?originalUrl="; +var webssoURLBase = "/api/v1/websso"; +var webssoURL = webssoURLBase + "?originalUrl="; var userAgent = navigator.userAgent.toLowerCase(); function get(name) { @@ -26,6 +27,11 @@ function get(name) { } } +function getSimpleParam(name) { + const params = new URLSearchParams(window.location.search); + return params.get(name); +} + function testSameOrigin(url) { var loc = window.location, a = document.createElement('a'); @@ -55,6 +61,35 @@ var keypressed = function(event) { } }; +var loadFederatedOpLinks = function() { + const ops = getSimpleParam("federatedOpNames")?.split(",") ?? []; + const container = $("#federated-op-container"); + + if (ops.length > 0) { + container.before(` +
+ Or +
+ `); + } + + ops.forEach(op => { + container.append(` +
+ 🌐 + Continue with ${op} +
+ `); + }); +}; + +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 8c69ec9c20..4a2bbc1379 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 @@ - + @@ -87,7 +87,7 @@ - +
@@ -114,5 +114,9 @@
+
+ +
+ 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 ba38735716..e2e3d2f810 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 a4841dbb8c..f7926ba9bd 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 " 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 557e815710..378c16a980 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 @@ -33,6 +33,7 @@ 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; @@ -54,10 +55,11 @@ 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 { @@ -205,6 +207,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha 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) { @@ -226,7 +229,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } 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); @@ -240,6 +244,14 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } } + 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); @@ -338,8 +350,8 @@ private Pair getTokenFromRequestBody(ServletRequest request) 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 a8e7b8f8de..7558f5a2ed 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.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 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 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 @@ protected String constructLoginURL(HttpServletRequest request) { delimiter = "&"; } + final Set 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 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 90c056f2ea..d96b2442ad 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.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 f0f14b365d..7501dbe4b4 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 void init(FilterConfig filterConfig) throws ServletException { @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 99600f8746..cfee258c34 100644 --- a/gateway-release/home/conf/topologies/knoxsso.xml +++ b/gateway-release/home/conf/topologies/knoxsso.xml @@ -73,6 +73,10 @@ main.ldapRealm.contextFactory.authenticationMechanism simple + + urls./api/v1/websso/federated/op + anon + urls./** authcBasic diff --git a/gateway-release/home/conf/users.ldif b/gateway-release/home/conf/users.ldif index 4f1c6a9552..999f824034 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 7df1c6c224..00566f9d78 100644 --- a/gateway-release/pom.xml +++ b/gateway-release/pom.xml @@ -524,5 +524,9 @@ org.apache.knox gateway-service-restcatalog + + org.apache.knox + gateway-service-knoxidf + 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 2e2482aa1d..139e51862b 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 7a7afadd13..a9d544fe85 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 2009872782..3f627bdac8 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 String descriptorsTableSql() { 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-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java new file mode 100644 index 0000000000..261b368998 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java @@ -0,0 +1,36 @@ +/* + * 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.database; + +import org.apache.knox.gateway.services.token.impl.TokenStateDatabase; + +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 dfe4a4ea90..564f793007 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 @@ private static void initialize( 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 a38026f9a3..2d7d12f134 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 void init(GatewayConfig config, Map options) throws Servic 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 0000000000..f78f96af6d --- /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 + *

+ * 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 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 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 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 0000000000..8ce9e550e9 --- /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 + *

+ * 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.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 findById(String identityId) { + return Optional.empty(); + } + + @Override + public Optional findByProviderAndSubject(String provider, String externalIssuer, String externalSubject) { + return Optional.empty(); + } + + @Override + public void init(GatewayConfig config, Map 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 0000000000..649bf30388 --- /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 + *

+ * 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.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 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 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 0000000000..d6f820a1d5 --- /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 + *

+ * 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.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 0000000000..8e26bdc1d5 --- /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 + *

+ * 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.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 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 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 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 dbc89d6950..c7579a4805 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 @@ 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 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 bd808747c5..93bc7845a7 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 0000000000..239cb5df1a --- /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 0000000000..13ae9ab113 --- /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 0000000000..7ed07b1b05 --- /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 0000000000..acaf1c3404 --- /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 0000000000..7152d4c71d --- /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 0000000000..0dc42c1f72 --- /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 ff58ad6f93..57271324c0 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 void testAddStartAndStop() throws ServiceLifecycleException { 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-service-knoxidf/pom.xml b/gateway-service-knoxidf/pom.xml new file mode 100644 index 0000000000..48296b435d --- /dev/null +++ b/gateway-service-knoxidf/pom.xml @@ -0,0 +1,115 @@ + + + + 4.0.0 + + org.apache.knox + gateway + 3.0.0-SNAPSHOT + + + gateway-service-knoxidf + gateway-service-knoxidf + + + + org.apache.knox + gateway-i18n + + + org.apache.knox + gateway-spi + + + org.apache.knox + gateway-provider-jersey + + + org.apache.knox + gateway-util-common + + + org.apache.knox + gateway-service-knoxtoken + + + + javax.annotation + javax.annotation-api + + + javax.ws.rs + javax.ws.rs-api + + + javax.servlet + javax.servlet-api + + + + com.google.guava + guava + + + commons-io + commons-io + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.uuid + java-uuid-generator + + + com.nimbusds + nimbus-jose-jwt + + + com.github.ben-manes.caffeine + caffeine + + + org.apache.commons + commons-lang3 + + + org.apache.commons + commons-text + + + org.apache.httpcomponents + httpclient + + + org.apache.httpcomponents + httpcore + + + org.glassfish.jersey.core + jersey-common + + + 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 0000000000..6200edc835 --- /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 + *

+ * 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.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 scopes = new HashSet<>(Arrays.asList(scope.split("\\s+"))); + + try (PrintWriter out = response.getWriter()) { + out.println(""); + out.println("Consent Required"); + out.println(""); + out.println(""); + out.println("

"); + out.println("

Application Consent Required

"); + out.printf(Locale.US, "

The application %s is requesting access to your account.

%n", clientId); + + if (!scopes.isEmpty()) { + out.println("

This application will be able to:

"); + out.println("
    "); + for (String s : scopes) { + out.printf(Locale.US, "
  • %s
  • %n", describeScope(s)); + } + out.println("
"); + } + + out.println("
"); + out.printf(Locale.US, "%n", state); + out.println("
"); + out.println(""); + out.println(""); + out.println("
"); + out.println("
"); + out.println("
"); + out.println(""); + out.println(""); + } + } + + 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 0000000000..94415de191 --- /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 + *

+ * 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.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 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 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 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 federatedTokens) { + final Response tokenResponse = getAuthenticationToken(); + if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) { + final Map 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 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 federatedTokens) { + final Map 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 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 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 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 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 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 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 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 0000000000..ce895549db --- /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 + *

+ * 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.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 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-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 new file mode 100644 index 0000000000..47f802b04d --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/JwksResource.java @@ -0,0 +1,39 @@ +/* + * 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.service.knoxidf; + +import org.apache.knox.gateway.service.knoxtoken.JWKSResource; + +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 0000000000..d673f3f454 --- /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 + *

+ * 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.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 claims; + + OIDCScope(Set claims) { + this.claims = Collections.unmodifiableSet(new HashSet<>(claims)); + } + + public Set getClaims() { + return claims; + } + + public static Set claimsForScopes(String scopeString) { + Set 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 0000000000..05675e4917 --- /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 + *

+ * 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.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 redirectUris; + private List 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 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 0000000000..54e697eb98 --- /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 + *

+ * 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.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 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 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 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 0000000000..c837605aed --- /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 + *

+ * 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.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 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 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-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.java new file mode 100644 index 0000000000..cfbb4647cb --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFServiceDeploymentContributor.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 + *

+ * 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.service.knoxidf.deploy; + +import org.apache.knox.gateway.jersey.JerseyServiceDeploymentContributorBase; + +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-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java new file mode 100644 index 0000000000..f39d3eb772 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/EmptyUserParamsProvider.java @@ -0,0 +1,28 @@ +/* + * 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.service.knoxidf.userparams; + +import java.util.HashMap; +import java.util.Map; + +public class EmptyUserParamsProvider implements UserParamsProvider { + + @Override + public Map 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 0000000000..a17248451b --- /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 + *

+ * 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.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 getParamsFor(String subjectName, String scope) { + Map userParams = new HashMap<>(); + if ("anonymous".equalsIgnoreCase(subjectName)) { + return userParams; + } + + Set 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 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 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 fetchRoles(LdapContext ctx, String userDn) throws Exception { + List roles = new ArrayList<>(); + + SearchControls groupControls = new SearchControls(); + groupControls.setSearchScope(SearchControls.ONELEVEL_SCOPE); + groupControls.setReturningAttributes(new String[]{"cn", "member"}); + + String groupsBase = "ou=groups," + ldapBaseDn; + NamingEnumeration 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 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-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java new file mode 100644 index 0000000000..d052ef0403 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProvider.java @@ -0,0 +1,30 @@ +/* + * 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.service.knoxidf.userparams; + +import java.util.Map; + +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 getParamsFor(String subjectName, String scope); +} diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java new file mode 100644 index 0000000000..7cdf319844 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/UserParamsProviderFactory.java @@ -0,0 +1,26 @@ +/* + * 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.service.knoxidf.userparams; + +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 0000000000..49fc687f12 --- /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 16e6762403..5da9f343eb 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.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 targetAudiences = new ArrayList<>(); + private final List targetAudiences = new ArrayList<>(); private boolean enableSession; private String signatureAlgorithm; private List 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 @@ private void handleCookieSetup() { 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 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 d23c693ee5..4c17c02db9 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.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 Response getAuthenticationToken() { 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 Response getAuthenticationToken() { return resp.responseBuilder.build(); } } + + protected void decorateResponseMap(Map 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 8da6f137b1..56ab3b3f87 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 maxTokenLifetime = Optional.empty(); private int tokenLimitPerUser; + private Map hardCodedClaimMappings; private boolean includeGroupsInTokenAllowed; private String tokenIssuer; private boolean enableDelegatedAuth; @@ -365,9 +367,37 @@ public void init() throws AliasServiceException, ServiceLifecycleException, KeyL .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"; @@ -670,7 +700,7 @@ public Response revoke(String token) { } 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."; @@ -722,22 +752,6 @@ private boolean triesToRevokeOwnToken(String tokenId, String revoker) throws Unk 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}) @@ -845,16 +859,17 @@ protected Response getAuthenticationToken() { 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 { @@ -940,10 +955,16 @@ protected UserContext buildUserContext(HttpServletRequest request) { protected static class UserContext { public final String userName; public final String createdBy; + private final Map userParams; public UserContext(String userName, String createdBy) { + this(userName, createdBy, Collections.emptyMap()); + } + + public UserContext(String userName, String createdBy, Map userParams) { this.userName = userName; this.createdBy = createdBy; + this.userParams = userParams; } } @@ -1015,13 +1036,10 @@ protected Response enforceClientCertIfRequired() { 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); @@ -1035,7 +1053,7 @@ protected void persistTokenDetails(ResponseMap result, long expires, String user } } - 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; @@ -1079,7 +1097,7 @@ public ResponseMap(String accessToken, String tokenId, Map map, } } - 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); @@ -1087,8 +1105,9 @@ protected JWT getJWT(String userName, long expires, String jku) throws TokenServ final JWTokenAttributesBuilder jwtAttributesBuilder = new JWTokenAttributesBuilder(); jwtAttributesBuilder .setIssuer(tokenIssuer) - .setUserName(userName) + .setUserName(userContext.userName) .setAlgorithm(signatureAlgorithm) + .setIssueTime(issueTime) .setExpires(expires) .setManaged(managedToken) .setJku(jku) @@ -1111,6 +1130,14 @@ protected JWT getJWT(String userName, long expires, String jku) throws TokenServ 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 1537f698b2..2f28b96293 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 5caeac0e6a..21794bc21b 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 0000000000..3c025ccb82 --- /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 + *

+ * 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.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 attributes = new HashMap<>(); + + public FederatedIdentity(String userId, String provider, String externalSubject, String externalIssuer, + Instant createdAt, Map 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 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 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/knoxidf/federation/FederatedIdentityService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.java new file mode 100644 index 0000000000..778b589a24 --- /dev/null +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.java @@ -0,0 +1,34 @@ +/* + * 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.knoxidf.federation; + +import org.apache.knox.gateway.services.Service; + +import java.util.Optional; + +public interface FederatedIdentityService extends Service { + + void addFederatedIdentity(FederatedIdentity identity); + + Optional findById(String identityId); + + Optional findByProviderAndSubject( + String provider, + String externalIssuer, + String externalSubject); +} + diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java new file mode 100644 index 0000000000..30cbce7b24 --- /dev/null +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceException.java @@ -0,0 +1,28 @@ +/* + * 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.knoxidf.federation; + +public class FederatedIdentityServiceException extends RuntimeException { + + 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 c41f983eb7..40f9f4712c 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 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> actorChain; + private final Map customAttributes; - JWTokenAttributes(String userName, List audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias, - char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set groups, String kid, String issuer) { - this(userName, audiences, algorithm, expires, signingKeystoreName, signingKeystoreAlias, signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, null); - } - - JWTokenAttributes(String userName, List audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias, - char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set 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 audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias, - char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set groups, String kid, String issuer, String clientId, List> actorChain) { + JWTokenAttributes(String userName, List audiences, String algorithm, long issueTime, long expires, String signingKeystoreName, String signingKeystoreAlias, + char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set groups, String kid, String issuer, String clientId, List> actorChain, + Map 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 getAudiences() { + return audiences; + } - public List 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 getGroups() { - return groups; - } + public Set 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 String getClientId() { public List> getActorChain() { return actorChain; } + + public Map 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 b70a84e6ef..8bc70d43de 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 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> actorChain; + private Map customAttributes; public JWTokenAttributesBuilder setUserName(String userName) { this.userName = userName; @@ -60,6 +62,11 @@ public JWTokenAttributesBuilder setAlgorithm(String algorithm) { 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 JWTokenAttributesBuilder setActorChain(List> actorCha return this; } + public JWTokenAttributesBuilder setCustomAttributes(Map 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 3bc7fe2cda..8df35babe5 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 @@ private void saveMetadata(String key, String value) { } public TokenMetadata(Map metadataMap) { - this.metadataMap.clear(); this.metadataMap.putAll(metadataMap); } @@ -151,12 +150,17 @@ public void markKnoxSsoCookie() { @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 17e82e0af5..4d0080fd57 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 @@ 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 72fb7a25ff..7805253b0c 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.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 static String getTokenId(final JWT token) { 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 4cb4d151ed..d756b034c0 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.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 b43a0b29a3..78ad648f0a 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 @@ 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 JWTToken(JWTokenAttributes jwtAttributes) { } 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 JWTToken(JWTokenAttributes jwtAttributes) { 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 String getClaims() { 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 1eec58fe97..88d9c93114 100644 --- a/gateway-util-common/pom.xml +++ b/gateway-util-common/pom.xml @@ -104,6 +104,23 @@ org.apache.httpcomponents httpclient + + + javax.ws.rs + javax.ws.rs-api + + + org.apache.commons + commons-text + + + com.github.ben-manes.caffeine + caffeine + + + com.google.guava + guava + 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 f0a8bf177b..ab49f7d636 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.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 map) { + return renderAsJsonString(map, false); + } + + public static String renderAsJsonString(Map 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 0000000000..239e0baf2c --- /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 + *

+ * 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.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 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 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 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 getRequestedScopes() { + return requestedScopes; + } + + public String getJoinedRequestedScopes() { + return String.join(" ", requestedScopes); + } + +} diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java new file mode 100644 index 0000000000..80f3d7110f --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadataStore.java @@ -0,0 +1,33 @@ +/* + * 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.util.knoxidf; + +public class AuthorizeRequestMetadataStore extends KnoxIDFArtifactStore{ + + 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 0000000000..7ad6078433 --- /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 + *

+ * 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.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 0000000000..f1efc3d75b --- /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 + *

+ * 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.util.knoxidf; + +import javax.servlet.ServletContext; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class FederatedOpConfigurationFactory { + + public static Map createFederatedOpConfiguration(final ServletContext servletContext) { + final String names = servletContext.getInitParameter(KnoxIDFConstants.FEDERATED_OP_CONFIG_NAMES); + if (names == null || names.isEmpty()) { + return Collections.emptyMap(); + } + + final Map 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-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.java new file mode 100644 index 0000000000..80bbb1a04f --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationStore.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 + *

+ * 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.util.knoxidf; + +import java.util.Set; + +public class FederatedOpConfigurationStore extends KnoxIDFArtifactStore> { + + 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-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 new file mode 100644 index 0000000000..dbeba8141d --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java @@ -0,0 +1,39 @@ +/* + * 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.util.knoxidf; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.concurrent.TimeUnit; + +public abstract class KnoxIDFArtifactStore { + + private final Cache 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 0000000000..5573b90e24 --- /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 + *

+ * 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.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 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 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 0000000000..989a17e612 --- /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 + *

+ * 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.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 splitFederatedToken(String token, boolean idToken) { + final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX; + final Map 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 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 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 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 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 c89c5b2625..69b57fde16 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,7 @@ gateway-service-metadata gateway-service-session gateway-openapi-ui + gateway-service-knoxidf @@ -289,6 +290,7 @@ 1.2.5 1.15.1 2.4.0-b180830.0438 + 5.2.0 2.4.1 6.4.0 4.0.4 @@ -1428,6 +1430,11 @@ knox-token-generation-ui ${project.version} + + org.apache.knox + gateway-service-knoxidf + ${project.version} + org.glassfish.jersey.containers jersey-container-servlet-core @@ -1443,6 +1450,11 @@ jersey-server ${jersey.version} + + org.glassfish.jersey.core + jersey-common + ${jersey.version} + org.glassfish.jersey.inject @@ -2043,6 +2055,11 @@ woodstox-core ${woodstox-core.version} + + com.fasterxml.uuid + java-uuid-generator + ${uuid.generator.version} + cglib From ca62c98fcba29de3c8166c094db8945e36b91e1c Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Tue, 21 Jul 2026 15:42:37 +0200 Subject: [PATCH 02/13] 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 a82920bdbc..d25bb88692 100644 --- a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml +++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml @@ -15,7 +15,7 @@ main.ldapRealm.contextFactory.url - ldap://ldap:33389 + ldaps://localhost:33390 main.ldapRealm.contextFactory.authenticationMechanism diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py index e801933749..5d11de0533 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 918cc37d25..cbbbacaf2d 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 @@ def test_discovery(self): 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 @@ def test_discovery(self): 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 @@ def test_client_credentials_flow(self): # 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 @@ 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"] + 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 @@ def test_authorization_code_flow(self): "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 @@ def test_authorization_code_flow(self): } 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 @@ def test_authorization_code_flow(self): 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 @@ def test_authorization_code_flow(self): "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 @@ def test_authorization_code_flow_pkce_s256(self): # 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 @@ def test_authorization_code_flow_pkce_s256(self): "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 @@ def test_authorization_code_flow_pkce_plain(self): 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 @@ def test_authorization_code_flow_pkce_plain(self): "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 @@ def test_authorization_code_flow_pkce_failure(self): """ 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 @@ def test_authorization_code_flow_pkce_failure(self): 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() From 11acc7e603155a853f123e701a56ee5b9f2ff288 Mon Sep 17 00:00:00 2001 From: hsheinblatt Date: Tue, 21 Jul 2026 13:31:28 -0700 Subject: [PATCH 03/13] KNOX-3355 - Add TrustedOidcIssuerService schema and interface (#1311) * KNOX-3355 - Add TrustedOidcIssuerService schema and interface Co-authored-by: Harrison --- .../database/AbstractDataSourceFactory.java | 4 + .../src/main/resources/conf/gateway-site.xml | 49 +++++++ .../createKnoxIDFTrustedOidcIssuersTable.sql | 23 ++++ ...ateKnoxIDFTrustedOidcIssuersTableDerby.sql | 22 ++++ ...teKnoxIDFTrustedOidcIssuersTableOracle.sql | 23 ++++ .../services/AbstractGatewayServicesTest.java | 3 +- .../TrustedOidcIssuerTest.java | 71 ++++++++++ .../TrustedOidcIssuersSchemaTest.java | 121 ++++++++++++++++++ .../knox/gateway/services/ServiceType.java | 3 +- .../trustedoidcissuer/TrustedOidcIssuer.java | 63 +++++++++ .../TrustedOidcIssuerService.java | 96 ++++++++++++++ 11 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTable.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableDerby.sql create mode 100644 gateway-server/src/main/resources/createKnoxIDFTrustedOidcIssuersTableOracle.sql create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerTest.java create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java create mode 100644 gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuer.java create mode 100644 gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerService.java 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 a9d544fe85..a76b219a9e 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 fda674c179..89215924ed 100644 --- a/gateway-server/src/main/resources/conf/gateway-site.xml +++ b/gateway-server/src/main/resources/conf/gateway-site.xml @@ -113,4 +113,53 @@ limitations under the License. Interceptor type. + + + \ 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 0000000000..a97a4a6cc8 --- /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 0000000000..a3e77b4656 --- /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 0000000000..2c0de4bd04 --- /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 57271324c0..9041118883 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 void testAddStartAndStop() throws ServiceLifecycleException { 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 0000000000..a728e99ac8 --- /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 + *

+ * 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.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 0000000000..e8cb56015c --- /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 + *

+ * 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.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 21794bc21b..85afaefae3 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 0000000000..fd4622209c --- /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 + *

+ * 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.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 0000000000..273dbfa372 --- /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 + *

+ * 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.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. + *

+ * 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. + *

+ * 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 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 list(); +} From 0ac3eca9fb1b73707560ee0049883818f3b39a3d Mon Sep 17 00:00:00 2001 From: hsheinblatt Date: Wed, 22 Jul 2026 06:25:19 -0700 Subject: [PATCH 04/13] KNOX-3355 - Add OIDCDiscoveryHelper, JdbcTrustedOidcIssuerService, and TrustedOidcIssuerServiceFactory (#1315) --- gateway-server/pom.xml | 4 + .../knox/gateway/database/DatabaseType.java | 29 +- .../services/DefaultGatewayServices.java | 2 + .../TrustedOidcIssuerServiceFactory.java | 105 ++++++ .../EmptyTrustedOidcIssuerService.java | 81 +++++ .../JdbcTrustedOidcIssuerService.java | 200 +++++++++++ .../OIDCDiscoveryHelper.java | 176 ++++++++++ .../TrustedOidcIssuerDatabase.java | 101 ++++++ .../TrustedOidcIssuerServiceMessages.java | 51 +++ ...pache.knox.gateway.services.ServiceFactory | 1 + .../EmptyTrustedOidcIssuerServiceTest.java | 64 ++++ .../JdbcTrustedOidcIssuerServiceTest.java | 325 ++++++++++++++++++ .../OIDCDiscoveryHelperTest.java | 303 ++++++++++++++++ .../TrustedOidcIssuerServiceFactoryTest.java | 265 ++++++++++++++ .../TrustedOidcIssuersSchemaTest.java | 2 + .../util/knoxidf/KnoxIDFConstants.java | 13 + pom.xml | 6 + 17 files changed, 1720 insertions(+), 8 deletions(-) create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerService.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelper.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java create mode 100644 gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceMessages.java create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/EmptyTrustedOidcIssuerServiceTest.java create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/OIDCDiscoveryHelperTest.java create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerServiceFactoryTest.java diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml index 3ddfb07ab8..012ffe8af2 100644 --- a/gateway-server/pom.xml +++ b/gateway-server/pom.xml @@ -417,6 +417,10 @@ com.nimbusds nimbus-jose-jwt + + com.nimbusds + oauth2-oidc-sdk + org.apache.knox 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 3f627bdac8..5052d5d5a0 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 String federatedIdentityAttrTableSql() { 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 2d7d12f134..39cf0aca0a 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 void init(GatewayConfig config, Map options) throws Servic 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 0000000000..daa8d11a4f --- /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 + *

+ * 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 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 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 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 0000000000..506b31d657 --- /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 + *

+ * 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.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 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 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 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 0000000000..0b1e0a31e7 --- /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 + *

+ * 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.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}. + *

+ * 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. + *

+ * 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> registrySnapshot = + new AtomicReference<>(Collections.emptyMap()); + + private AliasService aliasService; + private TrustedOidcIssuerDatabase database; + private OIDCDiscoveryHelper discoveryHelper; + private int maxTrustedIssuers; + + @Override + public void init(GatewayConfig config, Map 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 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 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 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 0000000000..2c830bebb7 --- /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 + *

+ * 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.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. + *

+ * 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. + *

+ * 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 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. + *

+ * {@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. + *

+ * {@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. + *

+ * {@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 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 0000000000..92d0fd5c5d --- /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 + *

+ * 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.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 selectAll() throws SQLException { + final List 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 0000000000..1918bec476 --- /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 + *

+ * 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.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 93bc7845a7..e67206f2c5 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 0000000000..c606010a5e --- /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 + *

+ * 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.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 0000000000..03bc54f766 --- /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 + *

+ * 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.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 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 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 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 0000000000..f978a38afc --- /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 + *

+ * 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.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 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 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 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 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 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 0000000000..be885e1ef8 --- /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 + *

+ * 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.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 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 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 e8cb56015c..8645017cc5 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 5573b90e24..99ba8b5e36 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 69b57fde16..406496f181 100644 --- a/pom.xml +++ b/pom.xml @@ -259,6 +259,7 @@ 2.2.8 4.1.135.Final 10.9.1 + 11.37.2 v22.20.0 4.12.0 5.2.2 @@ -1517,6 +1518,11 @@ nimbus-jose-jwt ${nimbus-jose-jwt.version} + + com.nimbusds + oauth2-oidc-sdk + ${oauth2-oidc-sdk.version} + net.minidev From dfdd98f0d3710c26ab2740dd63f6f21e4ae1644b Mon Sep 17 00:00:00 2001 From: hsheinblatt Date: Thu, 23 Jul 2026 02:48:43 -0700 Subject: [PATCH 05/13] 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 0b1e0a31e7..95246c0801 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 @@ private synchronized void reloadRegistrySnapshot() { 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 92d0fd5c5d..05b9d4f723 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 @@ List selectAll() throws SQLException { } 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 03bc54f766..9a7a9673f6 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 void testRefreshJwksUriForUnregisteredIsNoOp() { 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 406496f181..c0cd5a64ea 100644 --- a/pom.xml +++ b/pom.xml @@ -259,8 +259,8 @@ 2.2.8 4.1.135.Final 10.9.1 - 11.37.2 v22.20.0 + 11.37.2 4.12.0 5.2.2 6.5.3 From c0eb9b5832074873ecaf2ce4fe574e20af186d89 Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Thu, 23 Jul 2026 12:12:41 +0200 Subject: [PATCH 06/13] KNOX-3390: Moved gateway-level config to GatewayConfig and implemented the missing methods (#1322) --- .../config/impl/GatewayConfigImpl.java | 20 +++++++ .../JdbcTrustedOidcIssuerService.java | 26 ++------- .../JdbcTrustedOidcIssuerServiceTest.java | 54 +++++++++---------- .../knox/gateway/GatewayTestConfig.java | 21 ++++++++ .../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 061518537d..809bf7fd1e 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 @@ -1888,4 +1888,24 @@ public List getLDAPSSLEnabledCipherSuites() { 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 95246c0801..e72ed56b62 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 void init(GatewayConfig config, Map options) throws Servi 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 9a7a9673f6..bccd9274ae 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 @@ 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 static void setUpClass() throws Exception { 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 static void tearDownClass() { } @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 void setUp() throws ServiceLifecycleException, SQLException { // 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 void testReloadAfterMutation() { 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 void testMaxTrustedIssuers() throws ServiceLifecycleException { 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 a3b3d0d9a8..e7b3a007b1 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 @@ -1359,4 +1359,25 @@ public List getLDAPSSLEnabledCipherSuites() { 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 6b36e729bf..0b7da6734b 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 @@ -156,6 +156,18 @@ public interface GatewayConfig { String LDAP_SSL_KEYSTORE_PASSWORD_ALIAS = "gateway.ldap.ssl.keystore.password.alias"; String LDAP_SSL_ENABLED_CIPHER_SUITES = "gateway.ldap.ssl.enabled.cipher.suites"; + // 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 @@ -1217,4 +1229,13 @@ public interface GatewayConfig { Set getPropertyNames(); boolean getGroupUIServicesOnHomepage(); + + int getTrustedOidcIssuerMaxTrustedIssuers(); + + int getTrustedOidcIssuerDiscoveryCacheTtlSecs(); + + int getTrustedOidcIssuerDiscoveryConnectTimeoutMs(); + + int getTrustedOidcIssuerDiscoveryReadTimeoutMs(); + } From 3c30bfbb5af9aef446e484c3bd85687bf6f7ae72 Mon Sep 17 00:00:00 2001 From: hsheinblatt Date: Fri, 24 Jul 2026 01:43:07 -0700 Subject: [PATCH 07/13] KNOX-3368 - Trusted OIDC Issuer admin API for Knox IDF (#1327) --- .../knoxidf/TrustedOidcIssuersResource.java | 227 ++++++++ ...xIDFAdminServiceDeploymentContributor.java | 51 ++ ...ateway.deploy.ServiceDeploymentContributor | 1 + .../TrustedOidcIssuersResourceTest.java | 551 ++++++++++++++++++ ...AdminServiceDeploymentContributorTest.java | 145 +++++ .../apache/knox/gateway/audit/api/Action.java | 1 + .../knox/gateway/audit/api/ResourceType.java | 1 + 7 files changed, 977 insertions(+) create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java create mode 100644 gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java 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 0000000000..b8e8b393e7 --- /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 + *

+ * 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.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 parsed; + try { + parsed = MAPPER.readValue(body, new TypeReference>() {}); + } 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> 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 issuerToMap(TrustedOidcIssuer issuer) { + final Map 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 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 0000000000..060774a937 --- /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 + *

+ * 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.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 49fc687f12..1fca75abb8 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 0000000000..e082d3255d --- /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 + *

+ * 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.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 capturedIssuer = EasyMock.newCapture(); + mockService.register(EasyMock.capture(capturedIssuer)); + EasyMock.expectLastCall().once(); + final Capture 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 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 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 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 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> body = parseJsonList(response.getEntity().toString()); + assertEquals(2, body.size()); + + final Map 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 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> parseJsonList(String json) throws Exception { + return new ObjectMapper().readValue(json, new TypeReference>>() {}); + } + + private static Map findByIssuerUrl(List> 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 0000000000..cd3494b4cb --- /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 + *

+ * 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.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 capturedRole = EasyMock.newCapture(); + final Capture 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 7057d56b07..3a77f60bbe 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 @@ private 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 a9eb211868..7c06240564 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 @@ private 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"; } From 1d56df15133e1ae68370d67e70abd40d8850bf84 Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Fri, 24 Jul 2026 20:31:32 +0200 Subject: [PATCH 08/13] KNOX-3390: Corrected JUnit test coverage for max issuers (#1326) --- .../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 bccd9274ae..e74e476cfc 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 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 void testReloadAfterMutation() { 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 void testMaxTrustedIssuers() throws ServiceLifecycleException { 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 From 7b9730b18bb896a806955da32d607d4360438393 Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Fri, 24 Jul 2026 20:32:00 +0200 Subject: [PATCH 09/13] KNOX-3396: Return 4xx for type-mismatched register bodies and issuer-limit-reached instead of 500 (#1328) --- gateway-service-knoxidf/pom.xml | 2 +- .../knoxidf/RegisterIssuerRequest.java | 54 +++++++++++++++++++ .../knoxidf/TrustedOidcIssuersResource.java | 24 +++++---- .../TrustedOidcIssuersResourceTest.java | 40 +++++++++++++- 4 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegisterIssuerRequest.java diff --git a/gateway-service-knoxidf/pom.xml b/gateway-service-knoxidf/pom.xml index 48296b435d..3c5fbce588 100644 --- a/gateway-service-knoxidf/pom.xml +++ b/gateway-service-knoxidf/pom.xml @@ -73,7 +73,7 @@ com.fasterxml.jackson.core - jackson-core + jackson-annotations com.fasterxml.jackson.core 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 0000000000..eeb83c74f0 --- /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 + *

+ * 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.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 b8e8b393e7..4bced34ffe 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 Response registerIssuer(String body) { String outcome = ActionOutcome.FAILURE; try { - final Map parsed; + final RegisterIssuerRequest parsed; try { - parsed = MAPPER.readValue(body, new TypeReference>() {}); + 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 Response registerIssuer(String body) { "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 e082d3255d..b5cea534ef 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 void testAuditRegisterStorageFailure() { 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 @@ private static void assertErrorField(Response response, String expectedError) { body.contains(expectedError)); } + @SuppressWarnings("unchecked") private static List> parseJsonList(String json) throws Exception { - return new ObjectMapper().readValue(json, new TypeReference>>() {}); + return new ObjectMapper().readValue(json, List.class); } private static Map findByIssuerUrl(List> list, From 77c775b3901008efa90bd94017bf33779f92b40e Mon Sep 17 00:00:00 2001 From: hsheinblatt Date: Thu, 6 Aug 2026 10:50:09 -0700 Subject: [PATCH 10/13] 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 --- .../knoxidf/TrustedOidcIssuersResource.java | 2 +- ...xIDFAdminServiceDeploymentContributor.java | 20 +++++++++++-------- ...AdminServiceDeploymentContributorTest.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 4bced34ffe..cbfaff4013 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 @@ @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 060774a937..639027e8bb 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 @@ 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}. + * + *

The {@code knoxidf/admin/**?**} pattern is disjoint from the KNOXIDF role's + * {@code knoxidf/api/**?**} pattern, preventing KNOXIDF from serving admin endpoints.

+ * + *

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.

*/ public class KnoxIDFAdminServiceDeploymentContributor extends JerseyServiceDeploymentContributorBase { @@ -46,6 +50,6 @@ protected String[] getPackages() { @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 cd3494b4cb..578283fa44 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 void testPatterns() { 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 void testContributeService() throws Exception { contributor.contributeService(context, service); assertEquals("KNOXIDF_ADMIN", capturedRole.getValue()); - assertEquals("knoxidf/issuers-admin/**?**", capturedPattern.getValue()); + assertEquals("knoxidf/admin/**?**", capturedPattern.getValue()); } } From e9c2ab00b6070d70f5fc99729b868961ca46bbbf Mon Sep 17 00:00:00 2001 From: hsheinblatt Date: Thu, 6 Aug 2026 10:50:22 -0700 Subject: [PATCH 11/13] 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 --- ...ntityAssertionFilterTokenExchangeTest.java | 279 ++++++++++++++++++ .../jwt/filter/JWTFederationFilter.java | 104 ++++--- ...derationFilterHandleTokenExchangeTest.java | 262 ++++++++++++++++ 3 files changed, 603 insertions(+), 42 deletions(-) create mode 100644 gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java create mode 100644 gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java diff --git a/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java b/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java new file mode 100644 index 0000000000..f74ddd55f1 --- /dev/null +++ b/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java @@ -0,0 +1,279 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.identityasserter.common.filter; + +import org.apache.knox.gateway.audit.log4j.audit.Log4jAuditService; +import org.apache.knox.gateway.context.ContextAttributes; +import org.apache.knox.gateway.security.ActorChainPrincipal; +import org.apache.knox.gateway.security.ActorChainPrincipalImpl; +import org.apache.knox.gateway.security.ImpersonatedPrincipal; +import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.apache.knox.gateway.security.SubjectUtils; +import org.apache.knox.gateway.security.TokenExchangePrincipalImpl; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.logging.log4j.ThreadContext; +import org.easymock.EasyMock; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import javax.security.auth.Subject; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.security.PrivilegedExceptionAction; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Regression tests for the RFC 8693 token-exchange processing pipeline: + * {@link AbstractIdentityAssertionFilter#continueChainAsPrincipal} handling of + * {@code TokenExchangePrincipal} (TEP) and {@code ActorChainPrincipal}. + * + *

Each test constructs a Subject directly (bypassing the JWT filter) and runs it through + * a minimal anonymous subclass of {@link CommonIdentityAssertionFilter} with identity + * {@code mapUserPrincipal} (returns input unchanged) and null {@code mapGroupPrincipals} + * (no group mapping). A {@link SubjectCapturingChain} captures the Subject visible to + * downstream filters inside whatever doAs context is active at chain invocation time. + * + *

Abbreviations used: AIAF for AbstractIdentityAssertionFilter and + * TEP for TokenExchangePrincipal. + * + */ +public class AbstractIdentityAssertionFilterTokenExchangeTest { + + private CommonIdentityAssertionFilter filter; + private FilterConfig filterConfig; + + @Before + public void setUp() throws Exception { + filter = new CommonIdentityAssertionFilter() { + @Override + public String mapUserPrincipal(String principalName) { + return principalName; + } + + @Override + public String[] mapGroupPrincipals(String name, Subject subject, + ServletRequest request) { + return null; + } + }; + + ServletContext ctx = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE)) + .andReturn("test-topology").anyTimes(); + ctx.setAttribute( + EasyMock.eq(ContextAttributes.IMPERSONATION_ENABLED_ATTRIBUTE), + EasyMock.anyObject()); + EasyMock.expectLastCall().anyTimes(); + EasyMock.replay(ctx); + + filterConfig = EasyMock.createNiceMock(FilterConfig.class); + EasyMock.expect(filterConfig.getServletContext()).andReturn(ctx).anyTimes(); + EasyMock.expect(filterConfig.getInitParameter( + CommonIdentityAssertionFilter.PRINCIPAL_MAPPING)).andReturn(null).anyTimes(); + EasyMock.expect(filterConfig.getInitParameter( + CommonIdentityAssertionFilter.GROUP_PRINCIPAL_MAPPING)).andReturn(null).anyTimes(); + EasyMock.expect(filterConfig.getInitParameter( + CommonIdentityAssertionFilter.ADVANCED_PRINCIPAL_MAPPING)) + .andReturn("username").anyTimes(); + EasyMock.expect(filterConfig.getInitParameterNames()) + .andReturn(Collections.emptyEnumeration()).anyTimes(); + EasyMock.replay(filterConfig); + + filter.init(filterConfig); + ThreadContext.put(Log4jAuditService.MDC_AUDIT_CONTEXT_KEY, "dummy"); + } + + /** + * When TEP identifies different actor and subject, AIAF creates a new doAs Subject with an + * ImpersonatedPrincipal set to the subject identity and PrimaryPrincipal preserved as the actor. + */ + @Test + public void testTEPWithDifferentActorAndSubjectSetsUpImpersonation() throws Exception { + Subject subject = buildSubject( + new PrimaryPrincipal("sa-actor"), + new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null)); + + SubjectCapturingChain chain = runFilterWithSubject(subject); + + Assert.assertTrue("chain should have been called", chain.called); + Set impersonated = chain.subject.getPrincipals(ImpersonatedPrincipal.class); + Assert.assertEquals("Expected exactly one ImpersonatedPrincipal", 1, impersonated.size()); + Assert.assertEquals("ImpersonatedPrincipal should be end-user", "end-user", + impersonated.iterator().next().getName()); + Set primary = chain.subject.getPrincipals(PrimaryPrincipal.class); + Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, primary.size()); + Assert.assertEquals("PrimaryPrincipal should be sa-actor", "sa-actor", + primary.iterator().next().getName()); + } + + /** + * When TEP actor and subject are the same identity, no impersonation is needed and AIAF + * proceeds without adding an ImpersonatedPrincipal to the downstream Subject. + */ + @Test + public void testTEPWithSameActorAndSubjectSkipsImpersonation() throws Exception { + Subject subject = buildSubject( + new PrimaryPrincipal("alice"), + new TokenExchangePrincipalImpl("alice", null, "alice", null)); + + SubjectCapturingChain chain = runFilterWithSubject(subject); + + Assert.assertTrue("chain should have been called", chain.called); + Assert.assertTrue("ImpersonatedPrincipal set should be empty", + chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty()); + } + + /** + * When no TEP is present, AIAF proceeds normally without creating an ImpersonatedPrincipal + * and the downstream Subject contains no TokenExchangePrincipal. + */ + @Test + public void testNoTEPProceedsNormally() throws Exception { + Subject subject = buildSubject(new PrimaryPrincipal("alice")); + + SubjectCapturingChain chain = runFilterWithSubject(subject); + + Assert.assertTrue("chain should have been called", chain.called); + Assert.assertTrue("ImpersonatedPrincipal set should be empty", + chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty()); + Assert.assertNull("No TokenExchangePrincipal expected", + SubjectUtils.getTokenExchangePrincipal(chain.subject)); + } + + /** + * Principal mapping is applied to the subject identity from TEP (not to the actor identity). + * AIAF calls {@code mapUserPrincipal} on {@code tep.getSubjectPrincipalName()} and uses the + * mapped result as the ImpersonatedPrincipal; the actor (PrimaryPrincipal) is unchanged. + */ + @Test + public void testTEPAppliesPrincipalMappingToSubjectNotActor() throws Exception { + CommonIdentityAssertionFilter mappingFilter = new CommonIdentityAssertionFilter() { + @Override + public String mapUserPrincipal(String principalName) { + return "user@external".equals(principalName) ? "localuser" : principalName; + } + + @Override + public String[] mapGroupPrincipals(String name, Subject subject, + ServletRequest request) { + return null; + } + }; + mappingFilter.init(filterConfig); + + Subject subject = buildSubject( + new PrimaryPrincipal("sa-actor"), + new TokenExchangePrincipalImpl("user@external", null, "sa-actor", null)); + + SubjectCapturingChain chain = runFilterWithSubject(subject, mappingFilter); + + Set impersonated = chain.subject.getPrincipals(ImpersonatedPrincipal.class); + Assert.assertEquals("Expected exactly one ImpersonatedPrincipal", 1, impersonated.size()); + Assert.assertEquals("ImpersonatedPrincipal should be mapped value", "localuser", + impersonated.iterator().next().getName()); + Set primary = chain.subject.getPrincipals(PrimaryPrincipal.class); + Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, primary.size()); + Assert.assertEquals("PrimaryPrincipal should be actor (unmapped)", "sa-actor", + primary.iterator().next().getName()); + } + + /** + * The TokenExchangePrincipal is preserved in the new doAs Subject built by AIAF when + * impersonation is needed, so downstream filters can still read the delegation metadata. + */ + @Test + public void testTEPPreservedInDoAsSubject() throws Exception { + Subject subject = buildSubject( + new PrimaryPrincipal("sa-actor"), + new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null)); + + SubjectCapturingChain chain = runFilterWithSubject(subject); + + Assert.assertNotNull("TokenExchangePrincipal should be preserved in downstream Subject", + SubjectUtils.getTokenExchangePrincipal(chain.subject)); + } + + /** + * The ActorChainPrincipal is preserved in the new doAs Subject built by AIAF when + * impersonation is needed, so the full delegation chain history is available downstream. + */ + @Test + public void testActorChainPrincipalPreservedInDoAsSubject() throws Exception { + List> 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 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) () -> { + 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 378c16a980..3202328ee7 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 @@ -23,7 +23,6 @@ import org.apache.knox.gateway.provider.federation.jwt.JWTMessages; import org.apache.knox.gateway.security.ActorChainPrincipalImpl; import org.apache.knox.gateway.security.PrimaryPrincipal; -import org.apache.knox.gateway.security.TokenExchangePrincipal; 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; @@ -436,14 +435,20 @@ private boolean authenticateWithCookies(HttpServletRequest request, HttpServletR /** * Handle RFC 8693 token exchange flow. * - *

This method validates both the subject_token and actor_token parameters, - * creates a TokenExchangePrincipal with the identity information from both tokens, - * and establishes a Subject with the actor as the PrimaryPrincipal.

+ *

Validates the required subject_token and, when present, the optional actor_token. + * Builds a Subject carrying the appropriate principals and establishes the security + * context for downstream filters.

* - *

The TokenExchangePrincipal signals to the identity assertion layer that + *

When actor_token is present, the Subject has the actor as PrimaryPrincipal and a + * TokenExchangePrincipal that signals the identity assertion layer that * impersonation should be established with the subject as the ImpersonatedPrincipal.

* - * @param request the HTTP request containing subject_token and actor_token parameters + *

When actor_token is absent, the Subject has the subject itself as PrimaryPrincipal + * with no TokenExchangePrincipal. RFC 8693 requires the actor token to be optional. + * Note that headless delegation using ImpersonatedPrincipal is currently not represented + * in this path.

+ * + * @param request the HTTP request containing subject_token and optional actor_token parameters * @param response the HTTP response * @param chain the filter chain * @throws IOException if an I/O error occurs @@ -459,13 +464,18 @@ private void handleTokenExchange(HttpServletRequest request, HttpServletResponse return; } - // Extract actor_token (required for proper token exchange) + // actor_token is optional per RFC 8693 §2.1. When absent, the exchange is either + // a same-subject exchange (no delegation) or a headless delegation exchange where + // the actor is the subject itself and the target subject is in requested_subject. + // Downstream processing determines the exchange type from request parameters. + // + // If a future generic Knox topology uses grant_type=token-exchange for + // Hadoop-proxy delegation, headless delegation + // (actor_token absent, requested_subject != subject_token.sub) would also need + // a TokenExchangePrincipal so that AbstractIdentityAssertionFilter can set up + // Hadoop doAs impersonation. The filter would need to read requested_subject here + // and compare it to subject_token.sub to detect this case. String actorTokenValue = request.getParameter(ACTOR_TOKEN); - if (actorTokenValue == null || actorTokenValue.isEmpty()) { - handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "RFC 8693 token exchange requires actor_token parameter"); - return; - } try { // Parse and validate subject_token @@ -476,13 +486,15 @@ private void handleTokenExchange(HttpServletRequest request, HttpServletResponse } // Parse and validate actor_token - JWT actorToken = parseAndValidateJWT(request, response, chain, actorTokenValue); - if (actorToken == null) { - // Validation failed, error response already sent - return; + JWT actorToken = null; + if (actorTokenValue != null && !actorTokenValue.isEmpty()) { + actorToken = parseAndValidateJWT(request, response, chain, actorTokenValue); + if (actorToken == null) { + // Validation failed, error response already sent + return; + } } - // Create Subject with actor as PrimaryPrincipal and TokenExchangePrincipal Subject subject = createSubjectForTokenExchange(subjectToken, actorToken); continueWithEstablishedSecurityContext(subject, request, response, chain); @@ -520,36 +532,44 @@ private JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse /** * Create a Subject for RFC 8693 token exchange with proper principal setup. * - * @param subjectToken the validated subject token - * @param actorToken the validated actor token - * @return a Subject configured for token exchange + *

When actorToken is non-null (delegated exchange), the Subject has the actor as + * PrimaryPrincipal and a TokenExchangePrincipal carrying both subject and actor identities. + * The TokenExchangePrincipal signals the identity assertion layer to set up doAs + * impersonation with the subject as the delegated identity.

+ * + *

When actorToken is null (same-subject or headless delegation exchange), the Subject + * has the subject itself as PrimaryPrincipal with no TokenExchangePrincipal, so the + * identity assertion layer performs no impersonation for this exchange.

+ * + *

In both cases, if the subject_token carries an {@code act} claim, the delegation + * chain is preserved as an ActorChainPrincipal.

+ * + * @param subjectToken the validated subject token (required) + * @param actorToken the validated actor token, or null if actor_token was not provided + * @return a Subject configured for the token exchange */ private Subject createSubjectForTokenExchange(JWT subjectToken, JWT actorToken) { - // Extract identities from the tokens String subjectPrincipalName = subjectToken.getSubject(); - String subjectIssuer = subjectToken.getIssuer(); - String actorPrincipalName = actorToken.getSubject(); - String actorIssuer = actorToken.getIssuer(); - // Create principals for the Subject - // PrimaryPrincipal is the ACTOR (the authenticated party) - PrimaryPrincipal primaryPrincipal = - new PrimaryPrincipal(actorPrincipalName); - - // TokenExchangePrincipal carries metadata for identity assertion layer - TokenExchangePrincipal tokenExchangePrincipal = - new TokenExchangePrincipalImpl( - subjectPrincipalName, subjectIssuer, actorPrincipalName, actorIssuer); - - // Extract actor chain from subject_token (if present) using existing logic - List> actorChain = - TokenUtils.extractActorChain(subjectToken); - - // Create Subject with all necessary principals Set principals = new HashSet<>(); - principals.add(primaryPrincipal); - principals.add(tokenExchangePrincipal); - // Add ActorChainPrincipal if actor chain exists in subject_token + if (actorToken != null) { + // Delegated exchange: actor acts on behalf of subject. + // PrimaryPrincipal is the actor (the authenticated party performing the exchange). + // TokenExchangePrincipal carries both identities for the identity assertion layer. + String subjectIssuer = subjectToken.getIssuer(); + String actorPrincipalName = actorToken.getSubject(); + String actorIssuer = actorToken.getIssuer(); + principals.add(new PrimaryPrincipal(actorPrincipalName)); + principals.add(new TokenExchangePrincipalImpl(subjectPrincipalName, subjectIssuer, actorPrincipalName, actorIssuer)); + } else { + // No actor_token: same-subject or headless delegation exchange. + // PrimaryPrincipal is the subject itself; no TokenExchangePrincipal is created, + // so the identity assertion layer does not set up doAs impersonation. + principals.add(new PrimaryPrincipal(subjectPrincipalName)); + } + + // Preserve the delegation chain from the subject_token act claim, if present. + List> actorChain = TokenUtils.extractActorChain(subjectToken); if (!actorChain.isEmpty()) { principals.add(new ActorChainPrincipalImpl(actorChain)); } 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 0000000000..ca23a5ae70 --- /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 + *

+ * 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.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. + * + *

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). + * + *

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 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. + * + *

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> 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 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 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; + } +} From d8250d581a9cbd37f8dcbb6c0a631a4c85082cfb Mon Sep 17 00:00:00 2001 From: Harrison Date: Wed, 5 Aug 2026 00:25:10 -0700 Subject: [PATCH 12/13] KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute on token-exchange Test issues to improve: Several tests use a helper rather than a mock, DynamicJwksPassTokenAuthority. This causes ambiguity in what method precisely was called and what failed. Additionally, fixing that becomes more complex because the token exchange requests use both a subject token and an actor token, so both tokens are validated, and it's either ambiguous or complex to ensure that each token validation path is correct. EasyMock should allow a range of times(0, 1) to be called, so we can make the appropriate signature verification optional for negative tests. That is, we can write the tests so that the order of enforcement for each condition is arbitrary: all the other conditions would evaluate to true if executed first. Once the actor token is made optional, we can simplify the negative test cases to use only a subject token, and then specify the mocks precisely so each negative test case validates the correct methods are called, if called, and no extra methods are called, the test can be insensitive to the order of validation checks, and we can remove the DynamicJwksPassTokenAuthority helper. --- .../jwt/filter/AbstractJWTFilter.java | 153 ++-- .../jwt/filter/JWTFederationFilter.java | 37 + .../JWTFederationFilterTokenExchangeTest.java | 696 ++++++++++++++++++ .../util/knoxidf/KnoxIDFConstants.java | 1 + 4 files changed, 843 insertions(+), 44 deletions(-) create mode 100644 gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java 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 de4987caa1..75a4e22ee9 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 @@ protected boolean validateToken(final HttpServletRequest request, final HttpServ 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 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. + * + *

Return semantics: + *

    + *
  • 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.
  • + *
  • Empty set — not applicable for this request; caller rejects with 401.
  • + *
+ * + *

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 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 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 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 3202328ee7..a29c5cc9e1 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 @@ -24,6 +24,9 @@ import org.apache.knox.gateway.security.ActorChainPrincipalImpl; import org.apache.knox.gateway.security.PrimaryPrincipal; import org.apache.knox.gateway.security.TokenExchangePrincipalImpl; +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; @@ -44,6 +47,8 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.security.Principal; import java.text.ParseException; import java.util.Base64; @@ -51,6 +56,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Set; import static java.nio.charset.StandardCharsets.UTF_8; @@ -249,6 +255,10 @@ private static void addKnoxIDFAttributes(ServletRequest request, JWT token) { 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) { @@ -579,6 +589,33 @@ private Subject createSubjectForTokenExchange(JWT subjectToken, JWT actorToken) return new Subject(true, principals, emptySet, emptySet); } + @Override + protected Set 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 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/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 0000000000..54cb49231b --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java @@ -0,0 +1,696 @@ +/* + * 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.provider.federation; + +import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.SecurityContext; +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.TokenServiceException; +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.security.PublicKey; +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. + * + *

Change 1 — TOKEN_ISS_ATTRIBUTE ({@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). + * + *

Change 2 — Dynamic JWKS for token-exchange: 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. + * + *

NOTE: If both subject and actor tokens are present on a token-exchange request, both + * are validated through the same {@code validateToken()} path. It does not matter if only + * the subject token is present, both are present, or which use dynamic or static issuers + * for these validateToken tests. Only the paths through the validateToken method are tested. + * For the delegation use case, when both subject token and actor token are present, we + * expect the actor token to have the external issuer and the subject token to have the Knox + * issuer for the typical use case, so a specific test is added for this use case. + * + *

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. + * + *

Filter configuration: 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); actor token from KNOX_ISSUER + * (static path). The authority mock verifies the dynamic path calls verifyToken with the + * resolved JWKS URI, configured sig-alg, and type-verifier. The static path calls + * verifyToken with only the token (instance-key, no PEM or JWKS URLs configured). + */ + @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 SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", + new Date(System.currentTimeMillis() + 60000)); + + final Capture capturedDynamicJwt = EasyMock.newCapture(); + final Capture capturedStaticJwt = 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), // configured sig-alg + EasyMock.isA(JOSEObjectTypeVerifier.class))) // filter-configured type verifier + .andReturn(true).once(); + EasyMock.expect(mockAuth.verifyToken(EasyMock.capture(capturedStaticJwt))).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); + } + + /** + * 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 capturedDynamicJwt = EasyMock.newCapture(); + final Capture 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 SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", + 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), // configured sig-alg + EasyMock.isA(JOSEObjectTypeVerifier.class))) // filter-configured type verifier + .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(), actorJwt.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. {@code DynamicJwksPassTokenAuthority} + * makes JWKS signature verification always succeed, so the "Token has expired" rejection + * is the guaranteed outcome regardless of the order in which expiry and signature are checked. + * {@code verify(issuerSvc)} confirms the dynamic path was entered before the expiry check. + */ + @Test + public void testExpiredTokenRejectedOnDynamicPath() throws Exception { + ((TestJWTFederationFilter) handler).setTokenService(new DynamicJwksPassTokenAuthority(publicKey)); + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT expiredJwt = 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(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + expiredJwt.serialize(), actorJwt.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); + EasyMock.verify(issuerSvc, response); + } + + /** + * Dynamic JWKS resolved, but the token's NotBefore is in the future. + * {@code DynamicJwksPassTokenAuthority} makes JWKS signature verification always succeed, + * so the "NotBefore check failed" rejection is the guaranteed outcome regardless of the + * order in which nbf and signature are checked. + */ + @Test + public void testFutureNbfRejectedOnDynamicPath() throws Exception { + ((TestJWTFederationFilter) handler).setTokenService(new DynamicJwksPassTokenAuthority(publicKey)); + 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 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(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + nbfJwt.serialize(), actorJwt.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); + EasyMock.verify(issuerSvc, response); + } + + /** + * Dynamic JWKS resolved, but the token's audience does not match the required audience. + * {@code DynamicJwksPassTokenAuthority} makes signature verification always succeed, so + * the audience rejection is the guaranteed outcome regardless of validation order. + */ + @Test + public void testAudienceMismatchRejectedOnDynamicPath() throws Exception { + ((TestJWTFederationFilter) handler).setTokenService(new DynamicJwksPassTokenAuthority(publicKey)); + 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 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(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), actorJwt.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); + EasyMock.verify(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 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(false).once(); + // resolveJwksUri not expected — any call fails verify(), proving no HTTP fetch attempted + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), actorJwt.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 SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", + 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(), actorJwt.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 staticJwks = Set.of(new URI(staticJwksUrl)); + final Set 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 + // --------------------------------------------------------------------------- + + /** + * 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 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, 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.ACTOR_TOKEN)).andReturn(actorToken).anyTimes(); + EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes(); + return request; + } + + // --------------------------------------------------------------------------- + // Inner classes + // --------------------------------------------------------------------------- + + /** + * Token authority that always passes JWKS-URI-based verification and uses real RSA for the + * instance-key path. Allows tests to focus on non-signature failures (expiry, nbf, audiences) + * without binding the test outcome to the current order of validation checks. + */ + private static class DynamicJwksPassTokenAuthority extends TestJWTokenAuthority { + + DynamicJwksPassTokenAuthority(PublicKey pk) { + super(pk); + } + + @Override + public boolean verifyToken(JWT token, Set jwksurls, String algorithm, + JOSEObjectTypeVerifier typeVerifier) throws TokenServiceException { + return true; + } + } +} 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 99ba8b5e36..d757b573f9 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"; From 3a0d294938a68e911824e79a8338a653b440fe37 Mon Sep 17 00:00:00 2001 From: Harrison Date: Thu, 6 Aug 2026 17:50:35 -0700 Subject: [PATCH 13/13] Fix unit tests to be unambiguous, the negative tests to be order of validation independent, and ensure that both the single subject token and the subject plus actor token request paths are covered. --- .../JWTFederationFilterTokenExchangeTest.java | 194 ++++++++++-------- 1 file changed, 114 insertions(+), 80 deletions(-) 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 index 54cb49231b..4f25c47a2d 100644 --- 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 @@ -17,7 +17,6 @@ package org.apache.knox.gateway.provider.federation; import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; -import com.nimbusds.jose.proc.SecurityContext; 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; @@ -25,7 +24,6 @@ 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.TokenServiceException; import org.apache.knox.gateway.services.security.token.impl.JWT; import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants; import org.easymock.Capture; @@ -39,7 +37,6 @@ import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; import java.net.URI; -import java.security.PublicKey; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -63,13 +60,11 @@ * 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. * - *

NOTE: If both subject and actor tokens are present on a token-exchange request, both - * are validated through the same {@code validateToken()} path. It does not matter if only - * the subject token is present, both are present, or which use dynamic or static issuers - * for these validateToken tests. Only the paths through the validateToken method are tested. - * For the delegation use case, when both subject token and actor token are present, we - * expect the actor token to have the external issuer and the subject token to have the Knox - * issuer for the typical use case, so a specific test is added for this use case. + *

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. * *

NOTE: We do not test the specific failure modes {@code isTokenEnabled} or * {@code isIdleTimeoutLimitNotExceeded} in the dynamic JWKS path. It would require more complex @@ -119,10 +114,9 @@ protected void setGarbledTokenOnRequest(HttpServletRequest request, SignedJWT jw // --------------------------------------------------------------------------- /** - * Subject token from EXTERNAL_ISSUER (not in static list); actor token from KNOX_ISSUER - * (static path). The authority mock verifies the dynamic path calls verifyToken with the - * resolved JWKS URI, configured sig-alg, and type-verifier. The static path calls - * verifyToken with only the token (instance-key, no PEM or JWKS URLs configured). + * 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 { @@ -130,20 +124,16 @@ public void testDynamicIssuerAllowedSubjectExternal() throws Exception { 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 Capture capturedDynamicJwt = EasyMock.newCapture(); - final Capture capturedStaticJwt = 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), // configured sig-alg - EasyMock.isA(JOSEObjectTypeVerifier.class))) // filter-configured type verifier + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) .andReturn(true).once(); - EasyMock.expect(mockAuth.verifyToken(EasyMock.capture(capturedStaticJwt))).andReturn(true).once(); EasyMock.replay(mockAuth); ((TestJWTFederationFilter) handler).setTokenService(mockAuth); @@ -152,7 +142,7 @@ public void testDynamicIssuerAllowedSubjectExternal() throws Exception { EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); final HttpServletRequest request = buildTokenExchangeRequest( - subjectJwt.serialize(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); EasyMock.replay(request, response, issuerSvc); @@ -161,7 +151,6 @@ public void testDynamicIssuerAllowedSubjectExternal() throws Exception { 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); } @@ -228,15 +217,13 @@ public void testSignatureVerificationFails() throws Exception { final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject", new Date(System.currentTimeMillis() + 60000)); - final SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", - 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), // configured sig-alg - EasyMock.isA(JOSEObjectTypeVerifier.class))) // filter-configured type verifier + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) .andReturn(false).once(); EasyMock.replay(mockAuth); ((TestJWTFederationFilter) handler).setTokenService(mockAuth); @@ -246,7 +233,7 @@ public void testSignatureVerificationFails() throws Exception { EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); final HttpServletRequest request = buildTokenExchangeRequest( - subjectJwt.serialize(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); response.sendError(HttpServletResponse.SC_UNAUTHORIZED); EasyMock.expectLastCall().once(); @@ -260,27 +247,36 @@ public void testSignatureVerificationFails() throws Exception { } /** - * Dynamic JWKS resolved, but the token is expired. {@code DynamicJwksPassTokenAuthority} - * makes JWKS signature verification always succeed, so the "Token has expired" rejection - * is the guaranteed outcome regardless of the order in which expiry and signature are checked. - * {@code verify(issuerSvc)} confirms the dynamic path was entered before the expiry check. + * 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 { - ((TestJWTFederationFilter) handler).setTokenService(new DynamicJwksPassTokenAuthority(publicKey)); handler.init(new TestFilterConfig(getProperties())); final SignedJWT expiredJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", new Date(System.currentTimeMillis() - 60000)); - final SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", - new Date(System.currentTimeMillis() + 60000)); + + final Capture 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(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + expiredJwt.serialize(), buildContextWithIssuerService(issuerSvc)); final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has expired"); EasyMock.expectLastCall().once(); @@ -290,32 +286,43 @@ public void testExpiredTokenRejectedOnDynamicPath() throws Exception { handler.doFilter(request, response, chain); Assert.assertFalse(chain.doFilterCalled); - EasyMock.verify(issuerSvc, response); + 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. - * {@code DynamicJwksPassTokenAuthority} makes JWKS signature verification always succeed, - * so the "NotBefore check failed" rejection is the guaranteed outcome regardless of the - * order in which nbf and signature are checked. + * 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 { - ((TestJWTFederationFilter) handler).setTokenService(new DynamicJwksPassTokenAuthority(publicKey)); 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 SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", - new Date(System.currentTimeMillis() + 60000)); + + final Capture 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(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + 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(); @@ -325,32 +332,44 @@ public void testFutureNbfRejectedOnDynamicPath() throws Exception { handler.doFilter(request, response, chain); Assert.assertFalse(chain.doFilterCalled); - EasyMock.verify(issuerSvc, response); + 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. - * {@code DynamicJwksPassTokenAuthority} makes signature verification always succeed, so - * the audience rejection is the guaranteed outcome regardless of validation order. + * 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 { - ((TestJWTFederationFilter) handler).setTokenService(new DynamicJwksPassTokenAuthority(publicKey)); 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 SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", - new Date(System.currentTimeMillis() + 60000)); + + final Capture 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(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + 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(); @@ -360,7 +379,10 @@ public void testAudienceMismatchRejectedOnDynamicPath() throws Exception { handler.doFilter(request, response, chain); Assert.assertFalse(chain.doFilterCalled); - EasyMock.verify(issuerSvc, response); + if (capturedJwt.hasCaptured()) { + Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); + } + EasyMock.verify(mockAuth, issuerSvc, response); } // --------------------------------------------------------------------------- @@ -378,15 +400,13 @@ public void testUntrustedIssuerRejectedNoHttpCall() throws Exception { final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject", 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(false).once(); // resolveJwksUri not expected — any call fails verify(), proving no HTTP fetch attempted final HttpServletRequest request = buildTokenExchangeRequest( - subjectJwt.serialize(), actorJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); response.sendError(HttpServletResponse.SC_UNAUTHORIZED); EasyMock.expectLastCall().once(); @@ -409,15 +429,13 @@ public void testServiceUnavailable() throws Exception { final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject", new Date(System.currentTimeMillis() + 60000)); - final SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc", - 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(), actorJwt.serialize(), buildServletContext(gws)); + subjectJwt.serialize(), buildServletContext(gws)); final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); response.sendError(HttpServletResponse.SC_UNAUTHORIZED); EasyMock.expectLastCall().once(); @@ -565,6 +583,33 @@ public void testDynamicPathUsesRegistryJwksNotStaticJwks() throws Exception { // 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 @@ -661,6 +706,16 @@ private ServletContext buildServletContext(GatewayServices gws) { 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(); + // 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); @@ -672,25 +727,4 @@ private HttpServletRequest buildTokenExchangeRequest(String subjectToken, String return request; } - // --------------------------------------------------------------------------- - // Inner classes - // --------------------------------------------------------------------------- - - /** - * Token authority that always passes JWKS-URI-based verification and uses real RSA for the - * instance-key path. Allows tests to focus on non-signature failures (expiry, nbf, audiences) - * without binding the test outcome to the current order of validation checks. - */ - private static class DynamicJwksPassTokenAuthority extends TestJWTokenAuthority { - - DynamicJwksPassTokenAuthority(PublicKey pk) { - super(pk); - } - - @Override - public boolean verifyToken(JWT token, Set jwksurls, String algorithm, - JOSEObjectTypeVerifier typeVerifier) throws TokenServiceException { - return true; - } - } }