diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ffec53663e..d41f88fd3e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,6 +13,16 @@ ## Integration Tests (Please add or update integration tests [`.github/workflows/tests`](.github/workflows/tests) for the feature you are adding. If no unit test is added, please explain why. Check out [`.github/workflows/tests/README.md`](./workflows/tests/README.md) for instructions) +### Opt-in test suites (PR labels) +Some integration suites are expensive and are **not** run on every PR. Add the corresponding label to this PR to run them: + +| Label | Runs | +|-------|------| +| `test-federation` | KnoxIDF federation E2E (`test_knoxidf_federation.py`). Stands up a real Keycloak as an external OpenID Provider and drives the full broker flow. Adds a few minutes (image pull + realm import). | +| `skip-tests` | Skips the entire Docker Compose test job. | + +**When to add the label:** these labels only take effect on runs triggered by opening the PR, pushing a commit, or reopening the PR — the workflow does not run on a label change. Add the label **before opening the PR** (or before your next push). Adding it after the checks have already finished will **not** start a new run; push a commit or close/reopen the PR to trigger one with the label applied. + ## UI changes (If this patch involves UI changes, please attach a screen-shot; otherwise, remove this) diff --git a/.github/workflows/build/Dockerfile b/.github/workflows/build/Dockerfile index 1781ead94f..99e89d87b3 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 @@ -41,8 +42,13 @@ ADD .github/workflows/build/gateway-site.xml /knox-runtime/conf/gateway-site.xml ADD .github/workflows/build/conf/topologies/knoxtoken.xml /knox-runtime/conf/topologies/knoxtoken.xml ADD .github/workflows/build/conf/topologies/health.xml /knox-runtime/conf/topologies/health.xml ADD .github/workflows/build/conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml +ADD .github/workflows/build/conf/topologies/knoxldapcache.xml /knox-runtime/conf/topologies/knoxldapcache.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 +ADD .github/workflows/build/conf/topologies/knoxsso.xml /knox-runtime/conf/topologies/knoxsso.xml +ADD .github/workflows/build/conf/topologies/knoxidf-sso.xml /knox-runtime/conf/topologies/knoxidf-sso.xml RUN chown -R gateway /knox-runtime/ diff --git a/.github/workflows/build/conf/topologies/knoxidf-ldap.xml b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml new file mode 100644 index 0000000000..e7c3cd45d2 --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxidf-ldap.xml @@ -0,0 +1,82 @@ + + + + + authentication + ShiroProvider + true + + main.ldapRealm + org.apache.knox.gateway.shirorealm.KnoxLdapRealm + + + main.ldapRealm.userDnTemplate + uid={0},ou=people,dc=hadoop,dc=apache,dc=org + + + main.ldapRealm.contextFactory.url + ldaps://localhost:33390 + + + main.ldapRealm.contextFactory.authenticationMechanism + simple + + + urls./knoxidf/api/v1/.well-known/openid-configuration + anon + + + urls./knoxidf/api/v1/client/register + anon + + + urls./knoxidf/api/v1/authorize/callback + anon + + + urls./knoxidf/api/v1/jwks + anon + + + urls./** + authcBasic + + + + identity-assertion + Default + true + + + + + KNOXIDF + + knoxidf.knox.token.ttl + 60000 + + + knoxidf.knox.token.limit.per.user + -1 + + + + knoxidf.client.registration.anonymous.allowed + true + + + + knoxidf.auto.consent.enabled + true + + + token.exchange.topology.name + knoxidf-token + + + diff --git a/.github/workflows/build/conf/topologies/knoxidf-sso.xml b/.github/workflows/build/conf/topologies/knoxidf-sso.xml new file mode 100644 index 0000000000..596db8284d --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxidf-sso.xml @@ -0,0 +1,120 @@ + + + + + + + federation + SSOCookieProvider + true + + sso.authentication.provider.url + https://knox:8443/gateway/knoxsso/api/v1/websso + + + sso.unauthenticated.path.list + /knoxidf/api/v1/authorize/callback;/knoxidf/api/v1/jwks;/knoxidf/api/v1/.well-known/openid-configuration;/knoxidf/api/v1/client/register + + + + identity-assertion + Default + true + + + + + KNOXIDF + + knoxidf.knox.token.ttl + 60000 + + + knoxidf.knox.token.limit.per.user + -1 + + + knoxidf.client.registration.anonymous.allowed + true + + + knoxidf.auto.consent.enabled + true + + + token.exchange.topology.name + knoxidf-token + + + federated.op.names + keycloak + + + federated.op.keycloak.enabled + true + + + federated.op.keycloak.clientId + knox-client + + + federated.op.keycloak.clientSecret + knox-client-secret + + + federated.op.keycloak.authorize.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/auth + + + federated.op.keycloak.authorize.callback + https://knox:8443/gateway/knoxidf-sso/knoxidf/api/v1/authorize/callback + + + federated.op.keycloak.token.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/token + + + federated.op.keycloak.jwks.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/certs + + + federated.op.keycloak.issuer + http://keycloak:8080/realms/knox + + + federated.op.keycloak.userinfo.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/userinfo + + + federated.op.keycloak.signature.algorithm + RS256 + + + 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..7ed1be0107 --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxidf-token.xml @@ -0,0 +1,38 @@ + + + + + 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 + + + + knoxidf.auto.consent.enabled + true + + + diff --git a/.github/workflows/build/conf/topologies/knoxldap.xml b/.github/workflows/build/conf/topologies/knoxldap.xml index c89e9ee940..080e5663b1 100644 --- a/.github/workflows/build/conf/topologies/knoxldap.xml +++ b/.github/workflows/build/conf/topologies/knoxldap.xml @@ -63,9 +63,19 @@ limitations under the License. KNOXTOKEN - knoxsso.token.ttl + knox.token.ttl 86400000 + + + knox.token.exp.server-managed + true + + + + knox.token.renewer.whitelist + guest + KNOX-AUTH-SERVICE diff --git a/.github/workflows/build/conf/topologies/knoxldapcache.xml b/.github/workflows/build/conf/topologies/knoxldapcache.xml new file mode 100644 index 0000000000..534be7a184 --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxldapcache.xml @@ -0,0 +1,105 @@ + + + + + + authentication + ShiroProvider + true + + sessionTimeout + 30 + + + main.ldapRealm + org.apache.knox.gateway.shirorealm.KnoxLdapRealm + + + main.ldapRealm.userDnTemplate + uid={0},ou=people,dc=proxy,dc=org + + + main.ldapRealm.contextFactory.url + ldaps://localhost:33390 + + + main.ldapRealm.contextFactory.authenticationMechanism + simple + + + + main.cacheManager + org.apache.knox.gateway.shirorealm.KnoxCacheManager + + + main.securityManager.cacheManager + $cacheManager + + + main.ldapRealm.authenticationCachingEnabled + true + + + urls./** + authcBasic + + + + identity-assertion + HadoopGroupProvider + true + + group.principal.mapping + admin=longGroupName1,longGroupName2,longGroupName3,longGroupName4 + + + CENTRAL_GROUP_CONFIG_PREFIX + gateway.group.config. + + + + + KNOXTOKEN + + knoxsso.token.ttl + 86400000 + + + + KNOX-AUTH-SERVICE + + preauth.auth.header.actor.id.name + x-knox-actor-username + + + preauth.auth.header.actor.groups.prefix + x-knox-actor-groups + + + preauth.group.filter.pattern + [^\s]+ + + + diff --git a/.github/workflows/build/conf/topologies/knoxsso.xml b/.github/workflows/build/conf/topologies/knoxsso.xml new file mode 100644 index 0000000000..39c9cea6fb --- /dev/null +++ b/.github/workflows/build/conf/topologies/knoxsso.xml @@ -0,0 +1,105 @@ + + + + + + + authentication + ShiroProvider + true + + sessionTimeout + 30 + + + redirectToUrl + /${GATEWAY_PATH}/knoxsso/knoxauth/login.html + + + restrictedCookies + rememberme,WWW-Authenticate + + + main.ldapRealm + org.apache.knox.gateway.shirorealm.KnoxLdapRealm + + + main.ldapRealm.userDnTemplate + uid={0},ou=people,dc=hadoop,dc=apache,dc=org + + + main.ldapRealm.contextFactory.url + ldaps://localhost:33390 + + + main.ldapRealm.authenticationCachingEnabled + false + + + main.ldapRealm.contextFactory.authenticationMechanism + simple + + + urls./api/v1/websso/federated/op + anon + + + urls./** + authcBasic + + + + + identity-assertion + Default + true + + + + + knoxauth + + + + KNOXSSO + + knoxsso.token.ttl + 86400000 + + + knox.token.exp.server-managed + false + + + knoxsso.redirect.whitelist.regex + ^https?://knox:[0-9]+/gateway/.*$ + + + + diff --git a/.github/workflows/build/conf/topologies/knoxtoken.xml b/.github/workflows/build/conf/topologies/knoxtoken.xml index d103603d77..9b197e076d 100644 --- a/.github/workflows/build/conf/topologies/knoxtoken.xml +++ b/.github/workflows/build/conf/topologies/knoxtoken.xml @@ -29,12 +29,17 @@ limitations under the License. jwt.expected.sigalg RS256 + + + knox.token.exp.server-managed + true + KNOXTOKEN - knoxsso.token.ttl + knox.token.ttl 86400000 diff --git a/.github/workflows/build/gateway-site.xml b/.github/workflows/build/gateway-site.xml index 00698bebdb..002fadea5d 100644 --- a/.github/workflows/build/gateway-site.xml +++ b/.github/workflows/build/gateway-site.xml @@ -145,6 +145,14 @@ limitations under the License. gateway.ldap.base.dn dc=proxy,dc=org + + gateway.ldap.max.size.limit + 1000 + + + gateway.ldap.max.time.limit + 60000 + gateway.ldap.recursive.group.resolution true @@ -211,5 +219,10 @@ limitations under the License. gateway.ldap.interceptor.demoldap.groupMemberAttribute member + + + gateway.ldap.interceptor.demoldap.pageSize + 3 + diff --git a/.github/workflows/build/gateway.sh b/.github/workflows/build/gateway.sh index e4927b7b28..3d2bf949ef 100755 --- a/.github/workflows/build/gateway.sh +++ b/.github/workflows/build/gateway.sh @@ -34,7 +34,15 @@ keytool -genkeypair -alias ldaps -keyalg RSA -keysize 2048 \ # 2) Store the keystore password under the alias the LDAP SSL config resolves. /knox-runtime/bin/knoxcli.sh create-alias "$KEYSTORE_PASSWORD_ALIAS" --value "$KEYSTORE_PASSWORD" -# 3) Trust that certificate in the JVM default truststore (cacerts) so the JNDI-based +# 3) Provision the gateway-level JWK required for server-managed Knox token state (renew / revoke / enable / disable and JWTProvider enforcement). +/knox-runtime/bin/knoxcli.sh generate-jwk --jwkAlg HS256 --saveAlias knox.token.hash.key + +# 4) Trust that certificate in the JVM default truststore (cacerts) so the JNDI-based +# Shiro LDAP realm accepts it. This is additive - it does not remove the default CAs. +# keytool -exportcert -alias ldaps -rfc +# -keystore "$KEYSTORE" -storepass "$KEYSTORE_PASSWORD" -file /tmp/ldaps-cert.pem + +/knox-runtime/bin/knoxcli.sh generate-jwk --jwkAlg HS256 --saveAlias knox.token.hash.key # Shiro LDAP realm accepts it. This is additive - it does not remove the default CAs. keytool -exportcert -alias ldaps -rfc \ -keystore "$KEYSTORE" -storepass "$KEYSTORE_PASSWORD" -file /tmp/ldaps-cert.pem diff --git a/.github/workflows/compose/docker-compose.knoxidf-federation.yml b/.github/workflows/compose/docker-compose.knoxidf-federation.yml new file mode 100644 index 0000000000..0b2b4676d0 --- /dev/null +++ b/.github/workflows/compose/docker-compose.knoxidf-federation.yml @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with this +# work for additional information regarding copyright ownership. The ASF +# licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +#

+# 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. + +# Opt-in override that layers a real Keycloak (external OpenID Provider) onto the base +# compose stack and runs ONLY the KnoxIDF federation E2E test. It is intentionally not part +# of the default test run (which ignores test_knoxidf_federation.py) because it pulls the +# Keycloak image and adds minutes of startup. +# +# Usage: +# cd .github/workflows/compose +# IMAGE_TAG=knoxidf docker compose \ +# -f docker-compose.yml -f docker-compose.knoxidf-federation.yml \ +# up --build --abort-on-container-exit --exit-code-from tests + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.2.4 + command: + - start-dev + - --import-realm + environment: + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - KC_HOSTNAME_STRICT=false + - KC_HTTP_ENABLED=true + - KC_HEALTH_ENABLED=true + volumes: + - ./keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro + healthcheck: + # Keycloak 25+ serves health on the management port (9000). The image has no curl, so + # use bash's /dev/tcp per Keycloak's own recommendation. + test: + - CMD-SHELL + - > + exec 3<>/dev/tcp/localhost/9000; + echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3; + cat <&3 | grep -q 'UP' + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + + knox: + depends_on: + keycloak: + condition: service_healthy + + tests: + environment: + - KNOX_GATEWAY_URL=https://knox:8443/ + - KEYCLOAK_URL=http://keycloak:8080 + command: > + bash -c "pip install -r requirements.txt + && echo 'Waiting for knox...' + && sleep 30 + && pytest test_knoxidf_federation.py --junitxml=test-results-federation.xml" + depends_on: + knox: + condition: service_started + keycloak: + condition: service_healthy diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml index 727bca2633..1acb0a2792 100644 --- a/.github/workflows/compose/docker-compose.yml +++ b/.github/workflows/compose/docker-compose.yml @@ -92,7 +92,8 @@ services: # Point the fabric8 client used by the k8s ServiceAccountValidator at k3s. - KUBECONFIG=/k3s/knox-kubeconfig.yaml volumes: -# - ./topologies:/knox-runtime/conf/topologies + # Mount topologies from the workspace so config changes apply on restart + - ../build/conf/topologies:/knox-runtime/conf/topologies - ./logs:/knox-runtime/logs # - ./knoxshell:/knoxshell - k3s-output:/k3s:ro @@ -114,7 +115,7 @@ services: && pylint *.py && echo 'Waiting for knox...' && sleep 30 - && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --junitxml=test-results.xml" + && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --ignore=test_knoxidf_federation.py --junitxml=test-results.xml" depends_on: - knox diff --git a/.github/workflows/compose/keycloak/realm.json b/.github/workflows/compose/keycloak/realm.json new file mode 100644 index 0000000000..bf6f57b875 --- /dev/null +++ b/.github/workflows/compose/keycloak/realm.json @@ -0,0 +1,49 @@ +{ + "realm": "knox", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "clients": [ + { + "clientId": "knox-client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "knox-client-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "consentRequired": false, + "fullScopeAllowed": true, + "redirectUris": [ + "https://knox:8443/gateway/knoxidf-sso/knoxidf/api/v1/authorize/callback" + ], + "webOrigins": [ + "+" + ], + "defaultClientScopes": [ + "profile", + "email" + ], + "optionalClientScopes": [] + } + ], + "users": [ + { + "username": "alice", + "enabled": true, + "emailVerified": true, + "email": "alice@example.com", + "firstName": "Alice", + "lastName": "Example", + "credentials": [ + { + "type": "password", + "value": "alice-password", + "temporary": false + } + ] + } + ] +} diff --git a/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml index 94d613ff94..791a3b2a5d 100644 --- a/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml +++ b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml @@ -157,6 +157,14 @@ limitations under the License. gateway.ldap.base.dn dc=proxy,dc=org + + gateway.ldap.max.size.limit + 1000 + + + gateway.ldap.max.time.limit + 60000 + gateway.ldap.recursive.group.resolution true @@ -203,5 +211,10 @@ limitations under the License. gateway.ldap.interceptor.demoldap.groupMemberAttribute member + + + gateway.ldap.interceptor.demoldap.pageSize + 3 + diff --git a/.github/workflows/compose/single-eku/gateway-site.xml b/.github/workflows/compose/single-eku/gateway-site.xml index 4272c7b739..76f488b85f 100644 --- a/.github/workflows/compose/single-eku/gateway-site.xml +++ b/.github/workflows/compose/single-eku/gateway-site.xml @@ -211,6 +211,14 @@ limitations under the License. gateway.ldap.base.dn dc=proxy,dc=org + + gateway.ldap.max.size.limit + 1000 + + + gateway.ldap.max.time.limit + 60000 + gateway.ldap.recursive.group.resolution true @@ -257,5 +265,10 @@ limitations under the License. gateway.ldap.interceptor.demoldap.groupMemberAttribute member + + + gateway.ldap.interceptor.demoldap.pageSize + 3 + 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..5c39d25d2a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,6 +69,61 @@ jobs: # Run the tests service defined in docker-compose.yml docker compose -f ./.github/workflows/compose/docker-compose.yml up --exit-code-from tests tests + # KnoxIDF federation E2E runs only when the PR carries the 'test-federation' + # label. It stands up a real Keycloak (external OpenID Provider) via the + # override compose file and pulls a multi-hundred-MB image plus minutes of + # realm import, so it is opt-in rather than on every PR. It runs here, before + # the single-EKU steps, so 'knox' is still in its base (non-mTLS) config; the + # override only adds a keycloak dependency to knox, it does not reconfigure it. + - name: Start Keycloak + Knox for federation + if: contains(github.event.pull_request.labels.*.name, 'test-federation') + run: | + # 'up -d knox' blocks until keycloak is service_healthy (override depends_on). + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \ + up -d knox keycloak + + - name: Wait for federation stack to stabilize + if: contains(github.event.pull_request.labels.*.name, 'test-federation') + run: sleep 30 # Adjust as needed for services startup time + + - name: Run KnoxIDF Federation Tests + id: knoxidf_federation_tests + if: contains(github.event.pull_request.labels.*.name, 'test-federation') + run: | + # Emit a distinct JUnit file so it reports as its own test suite. + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \ + run --rm tests bash -c "pip install -r requirements.txt \ + && pytest test_knoxidf_federation.py --junitxml=test-results-federation.xml" + + # Evidence gathering mirrors the single-EKU dumps: on a federation failure, + # capture container status and the knox + keycloak logs so a broker-flow + # failure (bad callback/issuer/JWKS) can be told apart from a Keycloak that + # never came up. + - name: Dump federation diagnostics on failure + if: failure() && steps.knoxidf_federation_tests.outcome == 'failure' + run: | + echo '===== docker compose ps -a =====' + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \ + ps -a || true + echo '===== knox container logs =====' + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \ + logs --no-color knox || true + echo '===== keycloak container logs =====' + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.knoxidf-federation.yml \ + logs --no-color keycloak || true + echo '===== gateway.log =====' + cat ./.github/workflows/compose/logs/gateway.log || true + # Single-EKU mTLS runs as its own pass. Its override turns on # gateway.client.auth.needed=true, which would break the default # (no-client-cert) tests above, so the gateway is recreated with the @@ -153,6 +208,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 @@ -162,6 +225,29 @@ jobs: .github/workflows/tests/test-results.xml .github/workflows/tests/test-results-single-eku.xml .github/workflows/tests/test-results-single-eku-no-mtls.xml + .github/workflows/tests/test-results-federation.xml + + - name: Archive Knox Logs + if: always() + 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 diff --git a/.github/workflows/tests/README.md b/.github/workflows/tests/README.md index 97cb15a408..76c04b1a97 100644 --- a/.github/workflows/tests/README.md +++ b/.github/workflows/tests/README.md @@ -13,7 +13,7 @@ This directory contains Python integration tests that run as part of the GitHub Create a new Python file in this directory (`.github/workflows/tests/`). The filename **must** start with `test_` (e.g., `test_auth.py`) to be automatically discovered by the test runner. 2. **Implement Test Logic**: - Use the `unittest` framework to structure your tests. You can include multiple test methods in a single class, and multiple classes in a single file. Each method starting with `test_` will be executed as a separate test case. + Use `pytest` to structure your tests. Test functions must start with `test_`; test classes must start with `Test` and must not define an `__init__` method. Existing `unittest.TestCase` tests are also supported by pytest. ```python # Licensed to the Apache Software Foundation (ASF) under one or more @@ -31,42 +31,37 @@ This directory contains Python integration tests that run as part of the GitHub # See the License for the specific language governing permissions and # limitations under the License. - import unittest - import requests import os + + import requests import urllib3 # Suppress InsecureRequestWarning since we use verify=False for self-signed certs urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - class TestMyFeature(unittest.TestCase): - def setUp(self): - # Get the Knox Gateway URL from environment variables - # Default to localhost for local debugging outside Docker - self.base_url = os.environ.get("KNOX_GATEWAY_URL", "https://localhost:8443/") - - def test_my_endpoint(self): - """ - Description of what this test checks. - """ - url = f"{self.base_url}gateway/sandbox/webhdfs/v1/?op=LISTSTATUS" - - print(f"Testing URL: {url}") - - # Make the request - # verify=False is needed for the dev environment's self-signed certs - response = requests.get(url, verify=False) - - # Assertions - self.assertEqual(response.status_code, 200) - # Add more assertions as needed - - def test_another_endpoint(self): - """ - Another test case in the same class. - """ - # ... implementation ... - pass + # Default to localhost for local debugging outside Docker. + BASE_URL = os.environ.get("KNOX_GATEWAY_URL", "https://localhost:8443/") + + + def test_my_endpoint(): + """Verify that the WebHDFS endpoint returns a successful response.""" + url = f"{BASE_URL}gateway/sandbox/webhdfs/v1/?op=LISTSTATUS" + + # verify=False is needed for the dev environment's self-signed certificate. + response = requests.get(url, verify=False, timeout=30) + + assert response.status_code == 200 + + + def test_another_endpoint(): + """Add another independently discovered test.""" + response = requests.get( + f"{BASE_URL}gateway/health/v1/ping", + verify=False, + timeout=30, + ) + + assert response.status_code == 200 ``` 3. **Add Dependencies**: @@ -74,10 +69,10 @@ This directory contains Python integration tests that run as part of the GitHub ## Organizing Tests in Subdirectories -You can organize tests into subdirectories (e.g., `tests/auth/`, `tests/proxy/`). For the test runner to discover them: +You can organize tests into subdirectories (e.g., `tests/auth/`, `tests/proxy/`). Pytest recursively discovers matching test files: -1. The subdirectory **must** contain an `__init__.py` file (it can be empty). -2. The test files inside must still match the `test_*.py` pattern. +1. Test files must match the `test_*.py` pattern. +2. An `__init__.py` file is optional unless the tests need the directory to be importable as a package. **Example structure:** @@ -85,10 +80,8 @@ You can organize tests into subdirectories (e.g., `tests/auth/`, `tests/proxy/`) tests/ ├── test_health.py ├── auth/ -│ ├── __init__.py │ └── test_auth.py └── proxy/ - ├── __init__.py └── test_proxy.py ``` @@ -98,8 +91,8 @@ The tests run in a dedicated Docker container defined in `../compose/docker-comp 1. The `tests` service mounts this directory (`.github/workflows/tests/`) to `/tests` inside the container. 2. It installs dependencies from `requirements.txt`. -3. It waits for the `knox` service to be ready. -4. It runs `python -m unittest discover -p 'test_*.py'` to find and execute all test files. +3. It waits briefly for the `knox` service to start. +4. It runs `pytest`, excluding the single-EKU suites that are executed separately by the workflow. ## Skipping Tests on Pull Requests diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py index 0a773b44e6..ee812318bd 100644 --- a/.github/workflows/tests/common_utils.py +++ b/.github/workflows/tests/common_utils.py @@ -17,12 +17,15 @@ from __future__ import annotations +import base64 +import json import os import unittest from typing import Any import requests import urllib3 +from requests.auth import HTTPBasicAuth # Default timeout for HTTP calls to the gateway (self-signed TLS, CI). KNOX_REQUEST_TIMEOUT = 30 @@ -46,6 +49,11 @@ def knox_get(url: str, **kwargs: Any) -> requests.Response: return requests.get(url, **opts) +def basic_auth_get(url: str, username: str, password: str) -> requests.Response: + """GET url with HTTP Basic credentials (verify off, default timeout).""" + return knox_get(url, auth=HTTPBasicAuth(username, password)) + + def knox_post(url: str, **kwargs: Any) -> requests.Response: """POST against Knox with verify=False and default timeout unless overridden.""" opts: dict[str, Any] = {"verify": False, "timeout": KNOX_REQUEST_TIMEOUT} @@ -53,6 +61,20 @@ def knox_post(url: str, **kwargs: Any) -> requests.Response: return requests.post(url, **opts) +def knox_put(url: str, **kwargs: Any) -> requests.Response: + """PUT against Knox with verify=False and default timeout unless overridden.""" + opts: dict[str, Any] = {"verify": False, "timeout": KNOX_REQUEST_TIMEOUT} + opts.update(kwargs) + return requests.put(url, **opts) + + +def knox_delete(url: str, **kwargs: Any) -> requests.Response: + """DELETE against Knox with verify=False and default timeout unless overridden.""" + opts: dict[str, Any] = {"verify": False, "timeout": KNOX_REQUEST_TIMEOUT} + opts.update(kwargs) + return requests.delete(url, **opts) + + def collect_actor_group_values( response: requests.Response, prefix: str = "x-knox-actor-groups" ) -> list[str]: @@ -72,3 +94,32 @@ def assert_hsts_header(testcase: unittest.TestCase, response: requests.Response) """Assert the response includes the expected Strict-Transport-Security header.""" testcase.assertIn(HSTS_HEADER_NAME, response.headers) testcase.assertEqual(response.headers[HSTS_HEADER_NAME], HSTS_EXPECTED_VALUE) + +def get_token_id_display_text(uuid): + """ + Format the token ID for display, matching Knox's getTokenIDDisplayText logic. + """ + if uuid and len(uuid) == 36 and "-" in uuid: + first_dash = uuid.find('-') + last_dash = uuid.rfind('-') + return f"{uuid[:first_dash]}...{uuid[last_dash+1:]}" + return uuid + + +def get_token_claim(token, claim): + """ + Decodes a JWT token and returns the value of the specified claim. + """ + try: + payload_b64 = token.split('.')[1] + # URL-safe base64 decoding usually needs padding adjustment + missing_padding = len(payload_b64) % 4 + if missing_padding: + payload_b64 += '=' * (4 - missing_padding) + # Use urlsafe_b64decode just in case, though standard b64decode often works with padding + payload_json = base64.urlsafe_b64decode(payload_b64).decode('utf-8') + payload = json.loads(payload_json) + return payload.get(claim) + except (ValueError, IndexError, json.JSONDecodeError) as e: + print(f"Failed to decode token for claim '{claim}': {e}") + return None diff --git a/.github/workflows/tests/test_knox_ldap_cache.py b/.github/workflows/tests/test_knox_ldap_cache.py new file mode 100644 index 0000000000..8269e9a0cc --- /dev/null +++ b/.github/workflows/tests/test_knox_ldap_cache.py @@ -0,0 +1,99 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for Shiro authentication caching in KnoxLdapRealm. + +Runs against the live knox + ldap docker-compose stack via the knoxldapcache +topology, whose ShiroProvider wires the Ehcache-backed Knox cache manager and +enables authentication caching: + main.cacheManager = org.apache.knox.gateway.shirorealm.KnoxCacheManager + main.securityManager.cacheManager = $cacheManager + main.ldapRealm.authenticationCachingEnabled = true + +Guarantees: + * caching - repeated authentications of the same principal all succeed + (200); the first bind populates the EhcacheShiro cache and the + subsequent ones are served through it, so caching must not + break the auth result. (Cache hit/miss is confirmed separately + by inspecting the knox logs for KnoxCacheManager messages.) + * security - a wrong password is still rejected (401), so caching keyed on + the principal never authenticates bad credentials; and a + DN-injection username is still rejected (401), so the RFC 4514 + escaping remains effective with caching enabled. +""" + +import unittest + +from common_utils import basic_auth_get, gateway_base_url + +# Repeat count for the cache round-trip: first request populates the cache, +# the rest exercise the cached path. +REPEAT = 5 + + +class TestKnoxLdapCache(unittest.TestCase): + """Auth caching works and does not weaken credential or DN-injection checks.""" + + def setUp(self): + self.base_url = gateway_base_url() + # Topology name derives from the filename knoxldapcache.xml. + self.topology_url = self.base_url + "gateway/knoxldapcache/auth/api/v1/pre" + + def test_repeated_auth_is_served_and_succeeds(self): + """Repeated logins of the same user all succeed with caching enabled.""" + for attempt in range(REPEAT): + with self.subTest(attempt=attempt): + response = basic_auth_get( + self.topology_url, "guest", "guest-password" + ) + self.assertEqual( + response.status_code, + 200, + f"guest attempt {attempt} should authenticate; " + f"got {response.status_code}", + ) + self.assertEqual( + response.headers.get("x-knox-actor-username"), "guest" + ) + + def test_wrong_password_still_rejected(self): + """Caching is keyed on the principal but must not accept a bad password.""" + # Warm the cache with a valid login first. + warm = basic_auth_get(self.topology_url, "guest", "guest-password") + self.assertEqual(warm.status_code, 200) + # Same principal, wrong password: must be rejected despite a cache entry. + response = basic_auth_get(self.topology_url, "guest", "wrong-password") + self.assertEqual( + response.status_code, + 401, + f"wrong password must be rejected; got {response.status_code}", + ) + self.assertNotIn("x-knox-actor-username", response.headers) + + def test_dn_injection_still_rejected_with_caching(self): + """DN-escaping stays effective with caching on: injection user is 401.""" + response = basic_auth_get( + self.topology_url, "guest,ou=people,dc=proxy,dc=org", "guest-password" + ) + self.assertEqual( + response.status_code, + 401, + f"injection username must be rejected; got {response.status_code}", + ) + self.assertNotIn("x-knox-actor-username", response.headers) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tests/test_knox_ldap_dn_injection.py b/.github/workflows/tests/test_knox_ldap_dn_injection.py new file mode 100644 index 0000000000..246dafa922 --- /dev/null +++ b/.github/workflows/tests/test_knox_ldap_dn_injection.py @@ -0,0 +1,102 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for LDAP DN-injection hardening in KnoxLdapRealm. + +Runs against the live knox + ldap docker-compose stack via the knoxldap +topology, whose ShiroProvider binds with: + main.ldapRealm.userDnTemplate = uid={0},ou=people,dc=proxy,dc=org + +Two guarantees: + * regression - legitimate demo-LDAP users still authenticate (HTTP 200); + * injection - usernames carrying DN metacharacters do not authenticate + (HTTP 401), because the username is RFC 4514-escaped before + it is substituted into the bind DN, so it cannot alter the + DN structure and resolves to no real directory entry. +""" + +import unittest + +from requests.auth import HTTPBasicAuth + +from common_utils import gateway_base_url, knox_get + +# Usernames whose DN metacharacters (',', '=', '*', '(', ')') would rewrite or +# widen the bind DN uid={0},ou=people,dc=proxy,dc=org if left unescaped. After +# RFC 4514 escaping each is a single literal uid value matching no entry, so the +# bind fails and Knox returns 401. None of these must ever authenticate. +INJECTION_USERNAMES = [ + "guest,ou=people,dc=proxy,dc=org", + "admin,ou=people,dc=proxy,dc=org", + "guest,ou=admin", + "*", + "guest)(uid=*", + "uid=admin,ou=people,dc=proxy,dc=org", +] + + +class TestKnoxLdapDnInjection(unittest.TestCase): + """Valid LDAP auth still works; DN-injection usernames are rejected.""" + + def setUp(self): + self.base_url = gateway_base_url() + # Topology name derives from the filename knoxldap.xml. + self.topology_url = self.base_url + "gateway/knoxldap/auth/api/v1/pre" + + def test_valid_guest_authenticates(self): + """Regression: a legitimate user still binds and authenticates (200).""" + response = knox_get( + self.topology_url, + auth=HTTPBasicAuth("guest", "guest-password"), + ) + self.assertEqual( + response.status_code, + 200, + f"guest should authenticate; got {response.status_code}", + ) + self.assertEqual(response.headers.get("x-knox-actor-username"), "guest") + + def test_valid_admin_authenticates(self): + """Regression: a second legitimate user still authenticates (200).""" + response = knox_get( + self.topology_url, + auth=HTTPBasicAuth("admin", "admin-password"), + ) + self.assertEqual( + response.status_code, + 200, + f"admin should authenticate; got {response.status_code}", + ) + self.assertEqual(response.headers.get("x-knox-actor-username"), "admin") + + def test_dn_injection_usernames_are_rejected(self): + """Injection: DN-metacharacter usernames must not authenticate (401).""" + for username in INJECTION_USERNAMES: + with self.subTest(username=username): + response = knox_get( + self.topology_url, + auth=HTTPBasicAuth(username, "guest-password"), + ) + self.assertEqual( + response.status_code, + 401, + f"injection username {username!r} must be rejected; " + f"got {response.status_code}", + ) + self.assertNotIn("x-knox-actor-username", response.headers) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tests/test_knoxidf.py b/.github/workflows/tests/test_knoxidf.py new file mode 100644 index 0000000000..b67fd0c52b --- /dev/null +++ b/.github/workflows/tests/test_knoxidf.py @@ -0,0 +1,349 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for Knox as an OIDC Identity Federation (IDF) provider.""" + +import unittest +import hashlib +import base64 +from urllib.parse import urlparse, parse_qs +from requests.auth import HTTPBasicAuth + +from common_utils import ( + gateway_base_url, + knox_get, + knox_post, + get_token_claim, +) + + +class TestKnoxIDF(unittest.TestCase): + """OIDC provider tests covering discovery, client credentials, and auth code flows.""" + + def setUp(self): + # Get the Knox Gateway URL from environment variables + self.base_url = gateway_base_url() + self.knoxidf_ldap_url = f"{self.base_url}gateway/knoxidf-ldap/" + self.knoxidf_token_url = f"{self.base_url}gateway/knoxidf-token/" + self.username = "guest" + self.password = "guest-password" + + def test_discovery(self): + """ + Test OIDC Discovery endpoint. + """ + url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/.well-known/openid-configuration" + print(f"Testing Discovery URL: {url}") + response = knox_get(url) + self.assertEqual(response.status_code, 200) + config = response.json() + + # Construct expected values based on dynamic base_url + expected_issuer = f"{self.knoxidf_ldap_url}knoxidf" + expected_auth_endpoint = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize" + expected_token_endpoint = f"{self.knoxidf_token_url}knoxidf/api/v1/token" + expected_userinfo_endpoint = f"{self.knoxidf_token_url}knoxidf/api/v1/userinfo" + expected_jwks_uri = f"{self.knoxidf_ldap_url}knoxidf/api/v1/jwks" + + self.assertEqual(config.get("issuer"), expected_issuer) + self.assertEqual(config.get("authorization_endpoint"), expected_auth_endpoint) + self.assertEqual(config.get("token_endpoint"), expected_token_endpoint) + self.assertEqual(config.get("userinfo_endpoint"), expected_userinfo_endpoint) + self.assertEqual(config.get("jwks_uri"), expected_jwks_uri) + + self.assertEqual(config.get("response_types_supported"), ["code"]) + self.assertEqual( + config.get("grant_types_supported"), + ["authorization_code", "refresh_token"], + ) + self.assertEqual(config.get("id_token_signing_alg_values_supported"), ["RS256"]) + # DEFAULT_SCOPES is an ImmutableSet, so discovery emits it in insertion order. + self.assertEqual( + config.get("scopes_supported"), + ["openid", "profile", "email", "offline_access"], + ) + + def test_client_credentials_flow(self): + """ + Test OIDC Client Credentials Flow. + """ + # 1. Register client + client_id, client_secret = self._register_test_client() + + # 2. Get token via client_credentials + token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token" + print(f"Getting token at: {token_url}") + data = { + "grant_type": "client_credentials", + "scope": "openid", + "client_id": client_id, + "client_secret": client_secret + } + # ClientCredentialsResource uses Basic Auth for client authentication + response = knox_post(token_url, data=data, verify=False) + if response.status_code != 200: + print(f"Token error response: {response.text}") + self.assertEqual(response.status_code, 200) + tokens = response.json() + self.assertIn("access_token", tokens) + self.assertEqual(tokens["token_type"], "Bearer") + + def test_authorization_code_flow(self): + """ + Test OIDC Authorization Code Flow with Refresh Token. + """ + # 1. Register client + client_id, client_secret = self._register_test_client() + + # 2. Authorize (with Basic Auth for the user 'guest'). Consent is auto-granted by the + # server (knoxidf.auto.consent.enabled=true in the topology), not by any client parameter. + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": "http://localhost/callback", + "scope": "openid offline_access", + "state": "test_state", + } + code = self._authorize_get_code(params, expect_state="test_state") + + # 3. Exchange code for tokens + token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token" + print(f"Exchanging code for tokens at: {token_url}") + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost/callback", + "client_id": client_id, + "client_secret": client_secret + } + response = knox_post(token_url, data=data, verify=False) + if response.status_code != 200: + print(f"Code exchange error: {response.text}") + self.assertEqual(response.status_code, 200) + tokens = response.json() + self.assertIn("access_token", tokens) + self.assertIn("id_token", tokens) + self.assertIn("refresh_token", tokens) + + refresh_token = tokens["refresh_token"] + print(f"Refresh token: {refresh_token}") + print(f"Refresh token knox.id: {get_token_claim(refresh_token, 'knox.id')}") + + # 4. Refresh the token (rotation) + print("Refreshing token...") + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret + } + response = knox_post(token_url, data=data, verify=False) + self.assertEqual(response.status_code, 200) + new_tokens = response.json() + self.assertIn("access_token", new_tokens) + self.assertIn("refresh_token", new_tokens) + + # Verify rotation: new refresh token should be different + self.assertNotEqual(refresh_token, new_tokens["refresh_token"]) + + # 5. Verify old refresh token is invalidated + print("Verifying old refresh token is invalidated...") + # Use same data (with old refresh_token) + data_old = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret + } + response = knox_post( + token_url, + data=data_old, + verify=False, + headers={"Accept": "application/json"}, + ) + # On the refresh_token grant, JWTFederationFilter pulls the refresh_token from the + # request body and validates it as an auth credential before TokenResource runs. + # Rotation revoked this token, so that filter-level check fails and returns 401 + # Unauthorized (a plain sendError HTML page, not the JSON invalid_grant body the + # resource would emit) -- the request never reaches handleRefreshToken. + self.assertEqual(response.status_code, 401) + + def test_authorization_code_flow_pkce_s256(self): + """ + Test OIDC Authorization Code Flow with PKCE (S256). + """ + # 1. Register client + client_id, client_secret = self._register_test_client() + + # 2. PKCE Setup + code_verifier = "thisshouldbealongandrandomstringthatissecure" + code_challenge = self._s256_challenge(code_verifier) + + # 3. Authorize + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": "http://localhost/callback", + "scope": "openid", + "state": "pkce_state", + "code_challenge": code_challenge, + "code_challenge_method": "S256" + } + code = self._authorize_get_code(params) + + # 4. Token Exchange + token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost/callback", + "client_id": client_id, + "client_secret": client_secret, + "code_verifier": code_verifier + } + response = knox_post(token_url, data=data) + self.assertEqual(response.status_code, 200) + tokens = response.json() + self.assertIn("access_token", tokens) + + def test_authorization_code_flow_pkce_plain_rejected(self): + """ + The 'plain' PKCE code_challenge_method offers no protection and is rejected: the authorize + endpoint must refuse to issue a code, returning invalid_request rather than redirecting. + """ + # 1. Register client + client_id, _ = self._register_test_client() + + # 2. PKCE Setup ('plain': challenge == verifier) + code_challenge = "some-plain-verifier" + + # 3. Authorize with the unsupported method -> 400 invalid_request, no redirect + auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize" + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": "http://localhost/callback", + "scope": "openid", + "state": "pkce_plain_state", + "code_challenge": code_challenge, + "code_challenge_method": "plain" + } + response = knox_get( + auth_url, + params=params, + auth=(self.username, self.password), + verify=False, + allow_redirects=False, + ) + self.assertEqual(response.status_code, 400) + error_info = response.json() + self.assertEqual(error_info["error"], "invalid_request") + self.assertIn("S256", error_info["error_description"]) + + def test_authorization_code_flow_pkce_failure(self): + """ + Test PKCE Failure scenarios. + """ + client_id, client_secret = self._register_test_client() + code_verifier = "correct-verifier" + code_challenge = self._s256_challenge(code_verifier) + + # Authorize + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": "http://localhost/callback", + "scope": "openid", + "state": "pkce_fail", + "code_challenge": code_challenge, + "code_challenge_method": "S256" + } + code = self._authorize_get_code(params) + + token_url = f"{self.knoxidf_token_url}knoxidf/api/v1/token" + + # 1. Invalid verifier -> invalid_grant (HTTP 400) + 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, 400) + self.assertIn("Invalid code_verifier", response.json()["error_description"]) + + # A code that fails validation is not consumed, but fetch a fresh one for a clean scenario. + code = self._authorize_get_code(params) + + # 2. Missing verifier -> invalid_grant (HTTP 400) + 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, 400) + self.assertIn("Missing code_verifier", response.json()["error_description"]) + + def _register_test_client(self): + reg_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/client/register" + print(f"Registering client at: {reg_url}") + data = { + "redirect_uris": "http://localhost/callback", + "allowed_scopes": "openid,profile,email,offline_access" + } + response = knox_post(reg_url, data=data, auth=HTTPBasicAuth(self.username, self.password)) + self.assertEqual(response.status_code, 200) + reg_info = response.json() + print(f"Registration response: {reg_info}") + return reg_info["client_id"], reg_info["client_secret"] + + def _authorize_get_code(self, params, expect_state=None): + """Hit the authorize endpoint and return the code from the redirect Location.""" + auth_url = f"{self.knoxidf_ldap_url}knoxidf/api/v1/authorize" + print(f"Authorizing at: {auth_url}") + # allow_redirects=False to catch the redirect to redirect_uri + response = knox_get( + auth_url, + params=params, + auth=(self.username, self.password), + verify=False, + allow_redirects=False, + ) + self.assertEqual(response.status_code, 303) + location = response.headers.get("Location") + self.assertIsNotNone(location) + self.assertTrue(location.startswith("http://localhost/callback")) + + query_params = parse_qs(urlparse(location).query) + self.assertIn("code", query_params) + if expect_state is not None: + self.assertIn("state", query_params) + self.assertEqual(query_params["state"][0], expect_state) + return query_params["code"][0] + + @staticmethod + def _s256_challenge(code_verifier): + digest = hashlib.sha256(code_verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().replace('=', '') + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/tests/test_knoxidf_federation.py b/.github/workflows/tests/test_knoxidf_federation.py new file mode 100644 index 0000000000..2806e0856a --- /dev/null +++ b/.github/workflows/tests/test_knoxidf_federation.py @@ -0,0 +1,234 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""End-to-end test of the KnoxIDF federation path: Knox brokering login to Keycloak. + +This is opt-in and NOT part of the default test run (the base docker-compose ignores it). +It runs only under the docker-compose.knoxidf-federation.yml override, which stands up a +real Keycloak as the external OpenID Provider. See that override file for how to run it. + +Flow exercised (all hops carried on one requests.Session so cookies persist): + 1. register an OIDC client on the knoxidf-sso topology (anonymous registration), + 2. GET /authorize -> SSOCookieProvider redirects to knoxsso with a federated-OP session id, + 3. GET /api/v1/websso/federated/op -> redirect to Keycloak's authorize endpoint, + 4. submit alice's credentials to Keycloak's login form -> redirect to the Knox callback, + 5. Knox validates the OP id_token, resumes /authorize, and redirects with a Knox code, + 6. exchange the Knox code at the token endpoint for Knox access/id/refresh tokens. +""" + +import os +import unittest +import uuid +from html.parser import HTMLParser +from urllib.parse import urljoin, urlparse, parse_qs + +import requests + +from common_utils import gateway_base_url, get_token_claim, KNOX_REQUEST_TIMEOUT + +# Client-side (relying-party) details. The redirect_uri is a loopback http URL, which the +# registration redirect-URI policy permits (RFC 8252). The client state is echoed back to +# the client redirect and is distinct from the federated-OP login session id. +CLIENT_REDIRECT_URI = "http://localhost/callback" +CLIENT_STATE = "knox_fed_client_state" + +# The seeded Keycloak realm user (see compose/keycloak/realm.json). +KC_USERNAME = "alice" +KC_PASSWORD = "alice-password" +KC_EMAIL = "alice@example.com" + + +class _LoginFormParser(HTMLParser): + """Scrapes the first HTML

: its action plus every input's name/value.""" + + def __init__(self): + super().__init__() + self.action = None + self.inputs = {} + self._in_form = False + + def handle_starttag(self, tag, attrs): + """Record the form action and any inputs inside the first form.""" + attributes = dict(attrs) + if tag == "form" and self.action is None: + self.action = attributes.get("action") + self._in_form = True + elif tag == "input" and self._in_form: + name = attributes.get("name") + if name: + self.inputs[name] = attributes.get("value", "") + + def handle_endtag(self, tag): + """Stop collecting inputs once the first form closes.""" + if tag == "form": + self._in_form = False + + +class TestKnoxIDFFederation(unittest.TestCase): + """Federation broker tests: Knox delegating authentication to Keycloak.""" + + def setUp(self): + self.base_url = gateway_base_url() + self.knoxidf_sso_url = f"{self.base_url}gateway/knoxidf-sso/" + self.knoxsso_url = f"{self.base_url}gateway/knoxsso/" + self.knoxidf_token_url = f"{self.base_url}gateway/knoxidf-token/" + self.keycloak_url = os.environ.get("KEYCLOAK_URL", "http://keycloak:8080") + + @staticmethod + def _new_session(): + """A cookie-carrying session that tolerates Knox's self-signed TLS.""" + session = requests.Session() + session.verify = False + return session + + def _register_client(self, session): + """Register a confidential client and return (client_id, client_secret).""" + url = f"{self.knoxidf_sso_url}knoxidf/api/v1/client/register" + payload = { + "redirect_uris": CLIENT_REDIRECT_URI, + "allowed_scopes": "openid,profile,email,offline_access", + } + response = session.post(url, data=payload, timeout=KNOX_REQUEST_TIMEOUT) + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + return body["client_id"], body["client_secret"] + + def _start_authorize(self, session, client_id): + """Kick off /authorize; return the federated-OP login session id from the redirect.""" + url = f"{self.knoxidf_sso_url}knoxidf/api/v1/authorize" + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": CLIENT_REDIRECT_URI, + "scope": "openid offline_access", + "state": CLIENT_STATE, + } + response = session.get(url, params=params, allow_redirects=False, + timeout=KNOX_REQUEST_TIMEOUT) + self.assertIn(response.status_code, (302, 303, 307), response.text) + location = response.headers.get("Location") + self.assertIsNotNone(location, "SSOCookieProvider did not issue a login redirect") + query = parse_qs(urlparse(location).query) + self.assertIn("federatedOpLoginSession", query, location) + self.assertIn("keycloak", query.get("federatedOpNames", [""])[0]) + return query["federatedOpLoginSession"][0] + + def _kickoff_federated_op(self, session, login_session_id): + """Select the Keycloak OP; return the Keycloak authorize URL Knox redirects to.""" + url = f"{self.knoxsso_url}api/v1/websso/federated/op" + params = {"fedOpSid": login_session_id, "fedOpName": "keycloak"} + response = session.get(url, params=params, allow_redirects=False, + timeout=KNOX_REQUEST_TIMEOUT) + self.assertIn(response.status_code, (302, 303, 307), response.text) + location = response.headers.get("Location") + self.assertIsNotNone(location) + self.assertTrue(location.startswith(self.keycloak_url), location) + return location + + def _keycloak_login(self, session, keycloak_authorize_url): + """Submit alice's credentials to Keycloak; return the Knox callback URL it redirects to.""" + page = session.get(keycloak_authorize_url, allow_redirects=True, + timeout=KNOX_REQUEST_TIMEOUT) + self.assertEqual(page.status_code, 200, "Keycloak did not render a login page") + parser = _LoginFormParser() + parser.feed(page.text) + self.assertIsNotNone(parser.action, "No login form found on the Keycloak page") + form = dict(parser.inputs) + form["username"] = KC_USERNAME + form["password"] = KC_PASSWORD + action = urljoin(page.url, parser.action) + submitted = session.post(action, data=form, allow_redirects=False, + timeout=KNOX_REQUEST_TIMEOUT) + self.assertIn(submitted.status_code, (302, 303), submitted.text) + location = submitted.headers.get("Location") + self.assertIsNotNone(location, "Keycloak did not redirect back after login") + self.assertTrue(location.startswith(self.base_url), location) + return location + + def _knox_callback(self, session, callback_url): + """Follow the OP callback into Knox; return the Knox authorization code.""" + response = session.get(callback_url, allow_redirects=False, + timeout=KNOX_REQUEST_TIMEOUT) + self.assertIn(response.status_code, (302, 303), response.text) + location = response.headers.get("Location") + self.assertIsNotNone(location) + self.assertTrue(location.startswith(CLIENT_REDIRECT_URI), location) + query = parse_qs(urlparse(location).query) + self.assertIn("code", query, location) + self.assertEqual(query.get("state", [""])[0], CLIENT_STATE) + return query["code"][0] + + def _exchange_code(self, session, client_id, client_secret, code): + """Exchange a Knox authorization code for the Knox token set.""" + url = f"{self.knoxidf_token_url}knoxidf/api/v1/token" + payload = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": CLIENT_REDIRECT_URI, + "client_id": client_id, + "client_secret": client_secret, + } + response = session.post(url, data=payload, timeout=KNOX_REQUEST_TIMEOUT) + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def _run_full_flow(self): + """Drive the whole broker flow on a fresh session; return the Knox token set.""" + session = self._new_session() + client_id, client_secret = self._register_client(session) + login_session_id = self._start_authorize(session, client_id) + keycloak_authorize_url = self._kickoff_federated_op(session, login_session_id) + callback_url = self._keycloak_login(session, keycloak_authorize_url) + code = self._knox_callback(session, callback_url) + return self._exchange_code(session, client_id, client_secret, code) + + def test_federation_returns_all_tokens(self): + """The brokered flow yields access, id, and refresh tokens with a usable token type.""" + tokens = self._run_full_flow() + self.assertIn("access_token", tokens) + self.assertIn("id_token", tokens) + self.assertIn("refresh_token", tokens) + self.assertEqual(tokens.get("token_type", "").lower(), "bearer") + self.assertGreater(int(tokens.get("expires_in", 0)), 0) + + def test_id_token_carries_federated_claims(self): + """The Knox id_token records the OP provenance and a standard email claim.""" + tokens = self._run_full_flow() + id_token = tokens["id_token"] + + self.assertEqual(get_token_claim(id_token, "federated_idp"), "KEYCLOAK") + self.assertEqual( + get_token_claim(id_token, "federated_iss"), + f"{self.keycloak_url}/realms/knox", + ) + self.assertTrue(get_token_claim(id_token, "federated_sub")) + self.assertEqual(get_token_claim(id_token, "email"), KC_EMAIL) + + # The Knox subject is a deterministic UUIDv5 derived from the OP issuer+subject. + subject = get_token_claim(id_token, "sub") + self.assertEqual(uuid.UUID(subject).version, 5) + + def test_same_keycloak_user_maps_to_stable_knox_subject(self): + """Two independent logins by the same OP user resolve to one persisted Knox subject.""" + first = self._run_full_flow() + second = self._run_full_flow() + first_sub = get_token_claim(first["id_token"], "sub") + second_sub = get_token_claim(second["id_token"], "sub") + self.assertTrue(first_sub) + self.assertEqual(first_sub, second_sub) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/tests/test_knoxtoken_jwt.py b/.github/workflows/tests/test_knoxtoken_jwt.py new file mode 100644 index 0000000000..db1141b71b --- /dev/null +++ b/.github/workflows/tests/test_knoxtoken_jwt.py @@ -0,0 +1,292 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""End-to-end tests for KNOXTOKEN issuance, lifecycle, and JWTProvider federation. + +These exercise the ``knoxtoken`` topology (JWTProvider federation) together +with the KNOXTOKEN service exposed by the ``knoxldap`` topology: + +1. A JWT is minted from the KNOXTOKEN service using Basic auth (knoxldap). +2. The resulting bearer token is presented to the JWTProvider-protected + ``knoxtoken`` topology, which must accept it and assert the caller's + identity. +3. Lifecycle operations (renew / revoke / enable / disable) require + ``knox.token.exp.server-managed=true`` on both the issuing service and + the JWTProvider so that revocation and disablement are enforced at + federation time — not just acknowledged by the management API. + +No other suite issues Knox tokens or authenticates via JWTProvider, so this +file does not overlap with the Basic-auth / preauth coverage elsewhere. +""" + +import unittest + +from requests.auth import HTTPBasicAuth + +from common_utils import gateway_base_url, knox_delete, knox_get, knox_put + + +class TestKnoxTokenJwt(unittest.TestCase): + """Mint a Knox JWT and use it against a JWTProvider-federated topology.""" + + def setUp(self): + self.base_url = gateway_base_url() + # KNOXTOKEN service lives in the knoxldap topology (Basic auth in front). + self.token_url = self.base_url + "gateway/knoxldap/knoxtoken/api/v1/token" + # Non-deprecated lifecycle paths (PUT renew / DELETE revoke). + self.token_v2_url = self.base_url + "gateway/knoxldap/knoxtoken/api/v2/token" + # JWTProvider-protected auth service in the knoxtoken topology. + self.federated_pre_url = self.base_url + "gateway/knoxtoken/auth/api/v1/pre" + + self.guest_auth = HTTPBasicAuth("guest", "guest-password") + self.admin_auth = HTTPBasicAuth("admin", "admin-password") + + def _issue_token(self, auth): + """Return the parsed JSON body of a freshly issued Knox token.""" + response = knox_get(self.token_url, auth=auth) + self.assertEqual( + response.status_code, + 200, + msg=f"Token issuance failed: {response.status_code} {response.text}", + ) + payload = response.json() + self.assertIn("access_token", payload) + self.assertIn("token_id", payload) + return payload + + def _federate(self, access_token): + """Present a bearer token to the JWTProvider-protected topology.""" + return knox_get( + self.federated_pre_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + def _assert_federates(self, access_token, expected_username): + response = self._federate(access_token) + self.assertEqual( + response.status_code, + 200, + msg=f"JWT was not accepted: {response.status_code} {response.text}", + ) + self.assertEqual( + response.headers.get("x-knox-actor-username"), + expected_username, + ) + + def _assert_federation_rejected(self, access_token): + """Assert that a bearer token is rejected by the JWTProvider topology.""" + response = self._federate(access_token) + self.assertEqual( + response.status_code, + 401, + msg=f"Expected federation rejection, got {response.status_code}: {response.text}", + ) + + def test_token_endpoint_returns_jwt_and_metadata(self): + """The KNOXTOKEN service returns a Bearer access_token plus metadata.""" + payload = self._issue_token(self.guest_auth) + + self.assertIn("token_type", payload) + self.assertIn("expires_in", payload) + self.assertEqual(payload["token_type"], "Bearer") + + # A serialized JWS has three dot-separated segments (header.payload.sig). + access_token = payload["access_token"] + self.assertEqual( + len(access_token.split(".")), + 3, + msg="access_token does not look like a signed JWT", + ) + + def test_token_requires_authentication(self): + """The token endpoint must reject anonymous callers with 401.""" + response = knox_get(self.token_url) + self.assertEqual(response.status_code, 401) + + def test_jwt_grants_access_to_federated_topology(self): + """A valid Knox JWT authenticates against the JWTProvider topology.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + self._assert_federates(access_token, "guest") + + def test_federated_topology_requires_token(self): + """The JWTProvider topology rejects requests that carry no token.""" + response = knox_get(self.federated_pre_url) + self.assertEqual(response.status_code, 401) + + def test_federated_topology_rejects_malformed_token(self): + """A structurally malformed bearer token must not be accepted (401).""" + response = knox_get( + self.federated_pre_url, + headers={"Authorization": "Bearer not.a.valid.jwt"}, + ) + self.assertEqual(response.status_code, 401) + + def test_federated_topology_rejects_wrong_signature(self): + """ + A parseable JWT with a bad signature must fail RS256 verification. + """ + access_token = self._issue_token(self.guest_auth)["access_token"] + + header, payload, signature = access_token.split(".") + mid = len(signature) // 2 + replacement = "A" if signature[mid] != "A" else "B" + tampered_signature = signature[:mid] + replacement + signature[mid + 1 :] + tampered = ".".join([header, payload, tampered_signature]) + self.assertNotEqual(tampered, access_token) + self.assertEqual(len(tampered.split(".")), 3) + + self._assert_federation_rejected(tampered) + + def test_revoke_is_enforced_at_federation(self): + """Mint → revoke → re-present must yield 401 (not just a revoked:true response).""" + payload = self._issue_token(self.guest_auth) + access_token = payload["access_token"] + self._assert_federates(access_token, "guest") + + revoke = knox_delete( + self.token_v2_url + "/revoke", + data=access_token, + auth=self.guest_auth, + ) + self.assertEqual( + revoke.status_code, + 200, + msg=f"Revoke failed: {revoke.status_code} {revoke.text}", + ) + self.assertEqual(revoke.json().get("revoked"), "true") + + self._assert_federation_rejected(access_token) + + def test_renew_extends_and_token_still_federates(self): + """A whitelisted renewer gets renewed:true and the token still federates.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + + renew = knox_put( + self.token_v2_url + "/renew", + data=access_token, + auth=self.guest_auth, + ) + self.assertEqual( + renew.status_code, + 200, + msg=f"Renew failed: {renew.status_code} {renew.text}", + ) + body = renew.json() + self.assertEqual(body.get("renewed"), "true") + self.assertIn("expires", body) + + self._assert_federates(access_token, "guest") + + def test_renew_forbidden_for_non_whitelisted_user(self): + """admin is not on knox.token.renewer.whitelist and must get 403 on renew.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + + renew = knox_put( + self.token_v2_url + "/renew", + data=access_token, + auth=self.admin_auth, + ) + self.assertEqual(renew.status_code, 403) + body = renew.json() + self.assertEqual(body.get("renewed"), "false") + self.assertIn("not authorized", body.get("error", "").lower()) + + def test_revoke_forbidden_for_non_owner_non_whitelisted_user(self): + """admin may not revoke guest's token without being on the renewer whitelist.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + + revoke = knox_delete( + self.token_v2_url + "/revoke", + data=access_token, + auth=self.admin_auth, + ) + self.assertEqual(revoke.status_code, 403) + body = revoke.json() + self.assertEqual(body.get("revoked"), "false") + self.assertIn("not authorized", body.get("error", "").lower()) + + def test_disable_is_enforced_at_federation(self): + """Disabling a token must stop federation; re-enabling restores it.""" + payload = self._issue_token(self.guest_auth) + access_token = payload["access_token"] + token_id = payload["token_id"] + self._assert_federates(access_token, "guest") + + disable = knox_put( + self.token_url + "/disable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual( + disable.status_code, + 200, + msg=f"Disable failed: {disable.status_code} {disable.text}", + ) + self.assertEqual(disable.json().get("setEnabledFlag"), "true") + self.assertEqual(disable.json().get("isEnabled"), "false") + self._assert_federation_rejected(access_token) + + enable = knox_put( + self.token_url + "/enable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual( + enable.status_code, + 200, + msg=f"Enable failed: {enable.status_code} {enable.text}", + ) + self.assertEqual(enable.json().get("setEnabledFlag"), "true") + self.assertEqual(enable.json().get("isEnabled"), "true") + self._assert_federates(access_token, "guest") + + def test_enable_already_enabled_returns_400(self): + """Enabling an already-enabled token returns 400 ALREADY_ENABLED.""" + token_id = self._issue_token(self.guest_auth)["token_id"] + + enable = knox_put( + self.token_url + "/enable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual(enable.status_code, 400) + body = enable.json() + self.assertEqual(body.get("setEnabledFlag"), "false") + self.assertIn("already enabled", body.get("error", "").lower()) + + def test_disable_already_disabled_returns_400(self): + """Disabling an already-disabled token returns 400 ALREADY_DISABLED.""" + token_id = self._issue_token(self.guest_auth)["token_id"] + + first = knox_put( + self.token_url + "/disable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual(first.status_code, 200) + + second = knox_put( + self.token_url + "/disable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual(second.status_code, 400) + body = second.json() + self.assertEqual(body.get("setEnabledFlag"), "false") + self.assertIn("already disabled", body.get("error", "").lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitignore b/.gitignore index 17236cdcdc..3d60d2dc7a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ velocity.log # Workflow rules .github/workflows/compose/logs/* .github/workflows/tests/test-results.xml +.github/workflows/tests/test-results-federation.xml # other IDEs and editors @@ -55,3 +56,6 @@ Thumbs.db # Test-generated keystore files (accidentally tracked; see KNOX-3328) gateway-server/data/security/keystores/*.jceks + +# Generated MkDocs build output +knox-site/site/ diff --git a/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..329ad930c2 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,37 @@ 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 => { + // Build the element via the DOM API and bind the handler in JS rather than interpolating + // the (attacker-controllable) op name into an HTML string / inline onclick attribute. + // .text() escapes the label; the click closure captures op without string injection. + const btn = $('
'); + $('').text("🌐").appendTo(btn); + $('').text("Continue with " + op).appendTo(btn); + btn.on("click", function() { loginWithOp(op); }); + container.append(btn); + }); +}; + +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..4606a81e02 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 @@ - + @@ -36,40 +36,86 @@ @@ -87,7 +133,7 @@ - +
@@ -114,5 +160,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-applications/src/main/resources/applications/knoxauth/app/styles/themes/DEPLOYMENT.md b/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/DEPLOYMENT.md index 220c3177a6..e746fdd331 100644 --- a/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/DEPLOYMENT.md +++ b/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/DEPLOYMENT.md @@ -332,7 +332,18 @@ Organizations with branding requirements or compliance needs should use `KNOX_TH ### Theme Name Validation -The theme loader only loads files from `styles/themes/THEME_NAME/theme.css`. Path traversal attacks (e.g., `?theme=../../etc/passwd`) are prevented by the URL structure. +The `?theme=` parameter and the saved localStorage preference are attacker-influenceable, +so the theme loader validates every candidate against `^[a-zA-Z0-9_-]{1,64}$` before it is +stored or used. This rejects quotes, angle brackets, dots and path separators, which +blocks both markup injection and path traversal (e.g. `?theme=../../etc/passwd`). +Validation is applied to values read back from localStorage as well as to the URL +parameter, so a value saved by an earlier visit cannot bypass it, and the stylesheet +element is built with DOM APIs rather than string concatenation. + +Because the only URL the loader can produce is `styles/themes/THEME_NAME/theme.css`, the +themes actually installed on the server are the effective allowlist. A name that does not +match an installed theme fails to load, the base Knox styles remain in effect, and the +saved preference is discarded. ### Content Security Policy @@ -340,6 +351,11 @@ If you have strict CSP, ensure it allows: - Loading CSS from same origin - Loading fonts from Google Fonts (if using modern theme) +A policy can be applied to the knoxauth route with the WebAppSec provider's +`SecurityHeaderFilter`, which emits arbitrary response headers from its init +parameters. Note that `login.html` currently uses inline scripts and inline event +handlers, so a policy for this page needs `'unsafe-inline'` for `script-src`. + Example CSP: ``` Content-Security-Policy: style-src 'self' https://fonts.googleapis.com; diff --git a/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/README.md b/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/README.md index e15aa77214..4bbfd58725 100644 --- a/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/README.md +++ b/gateway-applications/src/main/resources/applications/knoxauth/app/styles/themes/README.md @@ -441,9 +441,18 @@ Modern CSS features used: ## Security Considerations -1. **XSS Protection**: Theme names are not executed as code, only used to construct file paths -2. **Path Traversal**: Theme loader only loads files from `styles/themes/` directory -3. **Content Security Policy**: Ensure CSP allows loading external fonts if using Google Fonts +1. **Theme Name Validation**: Theme names arrive from untrusted sources (the `?theme=` + URL parameter and the saved localStorage preference), so each candidate must match + `^[a-zA-Z0-9_-]{1,64}$` before it is stored or used. Validation is applied on the + localStorage read path as well as the URL, and a value that fails is discarded. +2. **XSS Protection**: The stylesheet element is created with DOM APIs + (`document.createElement`) rather than by concatenating markup, so a theme name can + never be parsed as HTML. +3. **Path Traversal**: The validation pattern rejects dots and path separators, so the + only URL the loader can produce is `styles/themes/THEME_NAME/theme.css`. A name that + does not correspond to an installed theme simply fails to load and the base styles + remain in effect - the themes present on the server are the effective allowlist. +4. **Content Security Policy**: Ensure CSP allows loading external fonts if using Google Fonts ## Troubleshooting diff --git a/gateway-discovery-cm/src/main/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGenerator.java b/gateway-discovery-cm/src/main/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGenerator.java index 46f874ac5d..b250cef27b 100644 --- a/gateway-discovery-cm/src/main/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGenerator.java +++ b/gateway-discovery-cm/src/main/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGenerator.java @@ -37,6 +37,7 @@ public class IcebergRestServiceModelGenerator extends AbstractServiceModelGenera static final String HTTP_PATH = "hive_metastore_catalog_servlet_path"; static final String REST_CATALOG_ENABLED = "hive_rest_catalog_enabled"; static final String SSL_ENABLED = "hive_metastore_enable_ssl"; + static final String ENCRYPT_ALL_PORTS_ENV_VAR_NAME = "ENCRYPT_ALL_PORTS"; static final String DEFAULT_HTTP_PATH = "icecli"; @@ -75,7 +76,9 @@ public ServiceModel generateService(ApiService service, ApiRole role, ApiConfigList roleConfig, ApiServiceConfig coreSettingsConfig) throws ApiException { String hostname = role.getHostRef().getHostname(); - boolean sslEnabled = Boolean.parseBoolean(getServiceConfigValue(serviceConfig, SSL_ENABLED)); + final boolean hmsSslEnabled = Boolean.parseBoolean(getServiceConfigValue(serviceConfig, SSL_ENABLED)); + final boolean encryptAllPorts = isEncryptAllPorts(); + final boolean sslEnabled = hmsSslEnabled && encryptAllPorts; String scheme = sslEnabled ? "https" : "http"; String port = getHttpPort(serviceConfig); String httpPath = getHttpPath(serviceConfig); @@ -86,14 +89,19 @@ public ServiceModel generateService(ApiService service, ServiceModel model = createServiceModel(String.format(Locale.getDefault(), "%s://%s:%s/%s", scheme, hostname, port, httpPath)); - model.addServiceProperty(HTTP_PORT, getHttpPort(serviceConfig)); - model.addServiceProperty(HTTP_PATH, getHttpPath(serviceConfig)); + model.addServiceProperty(HTTP_PORT, port); + model.addServiceProperty(HTTP_PATH, httpPath); model.addServiceProperty(REST_CATALOG_ENABLED, getRestCatalogEnabled(serviceConfig)); - model.addServiceProperty(SSL_ENABLED, Boolean.toString(sslEnabled)); + model.addServiceProperty(SSL_ENABLED, Boolean.toString(hmsSslEnabled)); + model.addServiceProperty(ENCRYPT_ALL_PORTS_ENV_VAR_NAME, Boolean.toString(encryptAllPorts)); return model; } + boolean isEncryptAllPorts() { + return Boolean.parseBoolean(System.getenv(ENCRYPT_ALL_PORTS_ENV_VAR_NAME)); + } + protected String getHttpPort(ApiServiceConfig serviceConfig) { return getServiceConfigValue(serviceConfig, HTTP_PORT); } diff --git a/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/AbstractServiceModelGeneratorTest.java b/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/AbstractServiceModelGeneratorTest.java index efa3445fe0..807194d055 100644 --- a/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/AbstractServiceModelGeneratorTest.java +++ b/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/AbstractServiceModelGeneratorTest.java @@ -79,9 +79,13 @@ protected boolean doTestHandles(final ServiceModelGenerator generator, protected ServiceModel createServiceModel(Map serviceConfig, Map roleConfig) { + return createServiceModel(newGenerator(), serviceConfig, roleConfig); + } + + protected ServiceModel createServiceModel(ServiceModelGenerator generator, Map serviceConfig, Map roleConfig) { ServiceModel model = null; try { - model = newGenerator().generateService(createApiServiceMock(getServiceType()), + model = generator.generateService(createApiServiceMock(getServiceType()), createApiServiceConfigMock(serviceConfig), createApiRoleMock(getRoleType()), createApiConfigListMock(roleConfig), diff --git a/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGeneratorTest.java b/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGeneratorTest.java index 1de14e6b26..04c39edd26 100644 --- a/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGeneratorTest.java +++ b/gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/model/hive/IcebergRestServiceModelGeneratorTest.java @@ -16,6 +16,7 @@ */ package org.apache.knox.gateway.topology.discovery.cm.model.hive; +import org.apache.knox.gateway.topology.discovery.cm.ServiceModel; import org.apache.knox.gateway.topology.discovery.cm.ServiceModelGenerator; import org.apache.knox.gateway.topology.discovery.cm.model.AbstractServiceModelGeneratorTest; import org.junit.Test; @@ -24,6 +25,7 @@ import java.util.HashMap; import java.util.Map; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -54,6 +56,7 @@ public void testServiceModel() { serviceConfig.put(IcebergRestServiceModelGenerator.HTTP_PATH, "icecli"); serviceConfig.put(IcebergRestServiceModelGenerator.REST_CATALOG_ENABLED, "true"); serviceConfig.put(IcebergRestServiceModelGenerator.SSL_ENABLED, "false"); + serviceConfig.put(IcebergRestServiceModelGenerator.ENCRYPT_ALL_PORTS_ENV_VAR_NAME, "false"); final Map roleConfig = Collections.emptyMap(); @@ -62,15 +65,36 @@ public void testServiceModel() { @Test public void testServiceModelSslEnabled() { + testServiceModelSsl(true); + } + + @Test + public void testServiceModelSslDisabledWhenEncryptionDisabled() { + testServiceModelSsl(false); + } + + private void testServiceModelSsl(boolean encryptionEnabled) { final Map serviceConfig = new HashMap<>(); serviceConfig.put(IcebergRestServiceModelGenerator.HTTP_PORT, "8091"); serviceConfig.put(IcebergRestServiceModelGenerator.HTTP_PATH, "icecli2"); serviceConfig.put(IcebergRestServiceModelGenerator.REST_CATALOG_ENABLED, "false"); serviceConfig.put(IcebergRestServiceModelGenerator.SSL_ENABLED, "true"); + serviceConfig.put(IcebergRestServiceModelGenerator.ENCRYPT_ALL_PORTS_ENV_VAR_NAME, Boolean.toString(encryptionEnabled)); final Map roleConfig = Collections.emptyMap(); - validateServiceModel(createServiceModel(serviceConfig, roleConfig), serviceConfig, roleConfig); + final ServiceModel model = createServiceModel(new IcebergRestServiceModelGenerator() { + @Override + boolean isEncryptAllPorts() { + return encryptionEnabled; + } + }, serviceConfig, roleConfig); + + validateServiceModel(model, serviceConfig, roleConfig); + + // HTTPS is only used when HMS SSL is enabled AND all ports are encrypted. + final String expectedScheme = encryptionEnabled ? "https" : "http"; + assertEquals(expectedScheme + "://localhost:8091/icecli2", model.getServiceUrl()); } @Override diff --git a/gateway-docker/src/main/resources/docker/Dockerfile b/gateway-docker/src/main/resources/docker/Dockerfile index 2e8fc72341..001a9aa153 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-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-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java b/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java index 352c3c9ef6..5e3cf4dfca 100644 --- a/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java +++ b/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java @@ -587,6 +587,7 @@ private HadoopAuthFilter testIfJwtSupported(String supportJwt) throws Exception expect(filterConfig.getInitParameter(JWTFederationFilter.JWKS_URLS)).andReturn(null).anyTimes(); expect(filterConfig.getInitParameter(JWTFederationFilter.TOKEN_PRINCIPAL_CLAIM)).andReturn(null).anyTimes(); expect(filterConfig.getInitParameter(JWTFederationFilter.TOKEN_VERIFICATION_PEM)).andReturn(null).anyTimes(); + expect(filterConfig.getInitParameter(JWTFederationFilter.TOKEN_EXCHANGE_DYNAMIC_JWKS_ALLOW_HTTP)).andReturn(null).anyTimes(); expect(filterConfig.getInitParameter(JWTFederationFilter.JWT_UNAUTHENTICATED_PATHS_PARAM)).andReturn(null).anyTimes(); expect(filterConfig.getInitParameter(AbstractJWTFilter.JWT_EXPECTED_ISSUER)).andReturn(null).anyTimes(); expect(filterConfig.getInitParameter(AbstractJWTFilter.JWT_EXPECTED_SIGALG)).andReturn(null).anyTimes(); diff --git a/gateway-provider-security-jwt/pom.xml b/gateway-provider-security-jwt/pom.xml index 4b729d39b2..9f6a7d2642 100644 --- a/gateway-provider-security-jwt/pom.xml +++ b/gateway-provider-security-jwt/pom.xml @@ -78,11 +78,6 @@ commons-lang3 - - org.jline - jline - - org.apache.knox gateway-test-utils diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTMessages.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTMessages.java index c8c4f3781e..b25b35a4cc 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTMessages.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/JWTMessages.java @@ -127,6 +127,9 @@ public interface JWTMessages { @Message(level = MessageLevel.ERROR, text = "Invalid URL ignored. Not a valid JWKS url {0}") void invalidJwksUrl(String jwksUrl); + @Message(level = MessageLevel.WARN, text = "Rejected insecure (non-HTTPS) dynamic JWKS URI {0} resolved for issuer {1}. Set knox.token.exchange.dynamic.jwks.allow.http=true on the provider to permit it.") + void rejectedInsecureDynamicJwksUri(String jwksUri, String issuer); + @Message(level = MessageLevel.ERROR, text = "Original redirect URL is not in the whitelist {0}") void invalidOriginalUrlDomain(String originalMainDomain); 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..bbf396dd6a 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(); } @@ -568,7 +633,7 @@ protected boolean validateToken(final HttpServletRequest request, final TokenMetadata tokenMetadata = tokenStateService == null ? null : tokenStateService.getTokenMetadata(tokenId); if (isTokenEnabled(tokenMetadata)) { if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) { - if (hasSignatureBeenVerified(passcode) || validatePasscode(tokenId, passcode)) { + if (hasSignatureBeenVerified(passcodeVerificationCacheKey(tokenId, passcode)) || validatePasscode(tokenId, passcode)) { markLastUsedAt(tokenId, tokenMetadata); return true; } else { @@ -589,7 +654,7 @@ protected boolean validateToken(final HttpServletRequest request, // 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(passcode); + removeSignatureVerificationRecord(passcodeVerificationCacheKey(tokenId, passcode)); handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, "Token has expired"); } } else { @@ -615,7 +680,7 @@ private boolean validatePasscode(String tokenId, String passcode) throws Unknown final byte[] storedPasscode = tokenMetadata == null ? null : tokenMetadata.getPasscode().getBytes(UTF_8); final boolean validPasscode = Arrays.equals(tokenMAC.hash(tokenId, issueTime, userName, passcode).getBytes(UTF_8), storedPasscode); if (validPasscode) { - recordSignatureVerification(passcode); + recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode)); } return validPasscode; } @@ -707,4 +772,7 @@ protected void removeSignatureVerificationRecord(final String token) { protected abstract void handleValidationError(HttpServletRequest request, HttpServletResponse response, int status, String error) throws IOException; + private String passcodeVerificationCacheKey(final String tokenId, final String passcode) { + return tokenId + "::" + passcode; + } } 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..2c1af9835c 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 @@ -21,10 +21,10 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.provider.federation.jwt.JWTMessages; -import org.apache.knox.gateway.security.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.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; @@ -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; @@ -44,24 +45,25 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.security.Principal; +import java.net.URI; +import java.net.URISyntaxException; import java.text.ParseException; import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.Locale; -import java.util.Map; +import java.util.Optional; 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 { - private static final JWTMessages LOGGER = MessagesFactory.get( JWTMessages.class ); /* A semicolon separated list of paths that need to bypass authentication */ public static final String JWT_UNAUTHENTICATED_PATHS_PARAM = "jwt.unauthenticated.path.list"; @@ -69,15 +71,35 @@ public class JWTFederationFilter extends AbstractJWTFilter { public static final String MISMATCHING_CLIENT_ID_AND_CLIENT_SECRET = "Client credentials flow with mismatching client_id and client_secret"; public static final String REFRESH_TOKEN = "refresh_token"; public static final String REFRESH_TOKEN_PARAM = "refresh_token"; - public static final String TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; - public static final String SUBJECT_TOKEN = "subject_token"; - public static final String ACTOR_TOKEN = "actor_token"; public static final String CLIENT_ASSERTION_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; public static final String CLIENT_ASSERTION_TYPE = "client_assertion_type"; public static final String CLIENT_ASSERTION = "client_assertion"; + // RFC 8693 constants + public static final String TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; + public static final String SUBJECT_TOKEN = "subject_token"; + public static final String ACTOR_TOKEN = "actor_token"; + public static final String SUBJECT_TOKEN_TYPE = "subject_token_type"; + public static final String ACTOR_TOKEN_TYPE = "actor_token_type"; + + // Set by doFilter only when it dispatches a genuine RFC 8693 token-exchange request (identified by + // getWireToken from the body-only grant_type). resolveRegisteredIssuerJwks trusts a runtime-registered + // external issuer's JWKS only when this attribute is present, binding that decision to the actual + // dispatched code path rather than to request.getParameter(GRANT_TYPE) -- which the Servlet API also + // populates from the URL query string, letting a plain Bearer request spoof it with ?grant_type=... + static final String TOKEN_EXCHANGE_REQUEST_ATTR = "knox.jwt.token.exchange.request"; + // RFC 8693 section 3 token type identifiers. Only JWT-family types are supported for exchange; + // Knox issues JWT access tokens, so the access_token URN is accepted as an alias for jwt. + public static final String TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"; + public static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"; + + // Topology provider param. OOTB the JWKS URI resolved via dynamic OIDC discovery for a + // runtime-registered external issuer MUST be HTTPS: fetching a token issuer's signing keys over + // cleartext would let an on-path attacker substitute their own keys and forge subject tokens. + // Set this to "true" on the provider to permit an http:// jwks_uri (e.g. an internal test OP). + public static final String TOKEN_EXCHANGE_DYNAMIC_JWKS_ALLOW_HTTP = "knox.token.exchange.dynamic.jwks.allow.http"; public enum TokenType { - JWT, Passcode; + JWT, Passcode, TokenExchange, AuthCode; } public static final String KNOX_TOKEN_AUDIENCES = "knox.token.audiences"; @@ -99,8 +121,14 @@ public enum TokenType { private String cookieName; private String paramName; + // OOTB false: a non-HTTPS dynamic-discovery JWKS URI is rejected. Only an explicit + // TOKEN_EXCHANGE_DYNAMIC_JWKS_ALLOW_HTTP="true" flips this, so a typo fails safe (secure). + private boolean allowInsecureDynamicJwks; private Set unAuthenticatedPaths = new HashSet<>(20); + // Handles RFC 8693 token exchange requests (see doFilter). + private TokenExchangeHandler tokenExchangeHandler = new TokenExchangeHandler(this); + @Override public void init( FilterConfig filterConfig ) throws ServletException { super.init(filterConfig); @@ -142,6 +170,12 @@ public void init( FilterConfig filterConfig ) throws ServletException { publicKey = CertificateUtils.parseRSAPublicKey(verificationPEM); } + // Topology toggle for permitting a non-HTTPS dynamic-discovery JWKS URI. Parsed with + // Boolean.parseBoolean so anything other than an explicit "true" (including a typo) keeps + // HTTPS enforcement on -- the fail-safe direction for a security control. + allowInsecureDynamicJwks = Boolean.parseBoolean( + filterConfig.getInitParameter(TOKEN_EXCHANGE_DYNAMIC_JWKS_ALLOW_HTTP)); + final String unAuthPathString = filterConfig .getInitParameter(JWT_UNAUTHENTICATED_PATHS_PARAM); /* prepare a list of allowed unauthenticated paths */ @@ -179,15 +213,6 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } } - // RFC 8693: Check if this is a token exchange request - HttpServletRequest httpRequest = (HttpServletRequest) request; - String grantType = httpRequest.getParameter(GRANT_TYPE); - if (TOKEN_EXCHANGE.equals(grantType)) { - // Handle RFC 8693 token exchange with subject_token and actor_token - handleTokenExchange(httpRequest, (HttpServletResponse) response, chain); - return; - } - Pair wireToken = null; try { wireToken = getWireToken(request); @@ -196,6 +221,29 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha throw e; } + // RFC 8693 token exchange: getWireToken flags this via TokenType.TokenExchange when the + // grant_type is in the request body. The subject_token/actor_token are read from the unwrapped + // request by the handler. Reading the body only happens on this (header-less) grant-flow path, + // so a proxied backend's body is never consumed by the header-authenticated path. + if (wireToken != null && TokenType.TokenExchange.equals(wireToken.getLeft())) { + // Bind the "this is token exchange" decision to the dispatched path so that only the + // subject_token/actor_token validated inside the handler can unlock registered-issuer JWKS. + request.setAttribute(TOKEN_EXCHANGE_REQUEST_ATTR, Boolean.TRUE); + tokenExchangeHandler.handle((HttpServletRequest) request, (HttpServletResponse) response, chain); + return; + } + + // authorization_code grant: the KnoxIDF token endpoint (TokenResource) authenticates the client + // itself -- a PKCE code_verifier for public clients, or a client_secret for confidential clients + // -- and binds the code to its client_id and redirect_uri. getWireToken flags this via + // TokenType.AuthCode when the grant_type is in the request body and no Bearer/Basic credentials + // were presented. Forward to the service without a gateway-established token so that public PKCE + // clients (no secret) are not rejected here. + if (wireToken != null && TokenType.AuthCode.equals(wireToken.getLeft())) { + continueWithAuthorizationCodeGrant(request, response, chain); + return; + } + if (wireToken != null && wireToken.getLeft() != null && wireToken.getRight() != null) { TokenType tokenType = wireToken.getLeft(); String tokenValue = wireToken.getRight(); @@ -205,6 +253,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 +275,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 +290,18 @@ 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, scope); + } + final String issuer = token.getIssuer(); + if (issuer != null) { + request.setAttribute(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE, issuer); + } + } + private void validateClientID(HttpServletRequest request, String tokenValue) { final String clientID = request.getParameter(CLIENT_ID); validateClientID(clientID, tokenValue); @@ -338,8 +400,12 @@ 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 (AUTH_CODE.equals(grantType)) { + // no client_secret parsed here; the KnoxIDF token endpoint authenticates the client + // (see the TokenType.AuthCode handling in doFilter) + return Pair.of(TokenType.AuthCode, null); + } else if (CLIENT_CREDENTIALS.equals(grantType)) { + if (CLIENT_ASSERTION_JWT_BEARER.equals(clientAssertionType)) { // short lived client assertion token expected return getClientTokenFromParams(unwrappedRequest, CLIENT_ASSERTION); } @@ -352,8 +418,9 @@ private Pair getTokenFromRequestBody(ServletRequest request) // refresh_token flow: the refresh_token parameter contains the actual token return getClientTokenFromParams(unwrappedRequest, REFRESH_TOKEN_PARAM); } else if (TOKEN_EXCHANGE.equals(grantType)) { - // token_exchange flow: the subject_token parameter contains the token to be exchanged - return getClientTokenFromParams(unwrappedRequest, SUBJECT_TOKEN); + // RFC 8693 token exchange: signal it via the token type. doFilter routes this to + // TokenExchangeHandler, which reads subject_token/actor_token from the unwrapped request. + return Pair.of(TokenType.TokenExchange, null); } return null; } @@ -421,67 +488,6 @@ private boolean authenticateWithCookies(HttpServletRequest request, HttpServletR return false; } - /** - * 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.

- * - *

The TokenExchangePrincipal signals to 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 - * @param response the HTTP response - * @param chain the filter chain - * @throws IOException if an I/O error occurs - * @throws ServletException if a servlet error occurs - */ - private void handleTokenExchange(HttpServletRequest request, HttpServletResponse response, FilterChain chain) - throws IOException, ServletException { - // Extract subject_token (required) - String subjectTokenValue = request.getParameter(SUBJECT_TOKEN); - if (subjectTokenValue == null || subjectTokenValue.isEmpty()) { - handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "RFC 8693 token exchange requires subject_token parameter"); - return; - } - - // Extract actor_token (required for proper token exchange) - 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 - JWT subjectToken = parseAndValidateJWT(request, response, chain, subjectTokenValue); - if (subjectToken == null) { - // Validation failed, error response already sent - return; - } - - // Parse and validate actor_token - JWT 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); - - } catch (ParseException e) { - LOGGER.failedToParsePasscodeToken(e); - handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, - "Failed to parse token in token exchange: " + e.getMessage()); - } - } - /** * Parse and validate a JWT token. * @@ -494,7 +500,8 @@ private void handleTokenExchange(HttpServletRequest request, HttpServletResponse * @throws IOException if an I/O error occurs during validation * @throws ServletException if a servlet error occurs during validation */ - private JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse response, + // package-private: also invoked by TokenExchangeHandler + JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse response, FilterChain chain, String tokenValue) throws ParseException, IOException, ServletException { JWT token = new JWTToken(tokenValue); @@ -505,46 +512,42 @@ private JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse return null; } - /** - * 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 - */ - 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 (!actorChain.isEmpty()) { - principals.add(new ActorChainPrincipalImpl(actorChain)); + @Override + protected Set resolveRegisteredIssuerJwks(String issuer, HttpServletRequest request) { + // Only a genuine token-exchange dispatch (see doFilter) may trust a runtime-registered external + // issuer's JWKS. Reading the request attribute -- not getParameter(GRANT_TYPE) -- prevents a + // ?grant_type= query param on a plain Bearer request from unlocking this path. + if (!Boolean.TRUE.equals(request.getAttribute(TOKEN_EXCHANGE_REQUEST_ATTR))) { + return Set.of(); } - - @SuppressWarnings("rawtypes") - HashSet emptySet = new HashSet(); - return new Subject(true, principals, emptySet, emptySet); + 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 { + final URI uri = new URI(jwksUri.get()); + // OOTB the discovered JWKS URI must be HTTPS (see TOKEN_EXCHANGE_DYNAMIC_JWKS_ALLOW_HTTP): + // signing keys fetched over cleartext could be swapped by an on-path attacker to forge + // subject tokens. Reject anything non-HTTPS unless the operator opted in. + if (!allowInsecureDynamicJwks && !"https".equalsIgnoreCase(uri.getScheme())) { + LOGGER.rejectedInsecureDynamicJwksUri(jwksUri.get(), issuer); + return Set.of(); + } + return Set.of(uri); + } catch (URISyntaxException e) { + LOGGER.unableToVerifyToken(e); + } + } + } + } + return Set.of(); } @Override @@ -586,6 +589,22 @@ private void continueWithAnonymousSubject(final ServletRequest request, } } + /** + * Forwards an {@code authorization_code} token request to the KnoxIDF token endpoint without a + * gateway-established token. The token endpoint ({@code TokenResource.validateAuthCode}) + * independently authenticates the client -- a PKCE {@code code_verifier} for public clients, or a + * {@code client_secret} for confidential clients -- and binds the code to its {@code client_id} + * and {@code redirect_uri}, so this filter only needs to let the request through with an anonymous + * subject. The principal of the issued token is derived from the authorization code's stored + * metadata, not from this subject. + */ + private void continueWithAuthorizationCodeGrant(final ServletRequest request, final ServletResponse response, final FilterChain chain) + throws ServletException, IOException { + final Subject subject = new Subject(); + subject.getPrincipals().add(new PrimaryPrincipal("anonymous")); + continueWithEstablishedSecurityContext(subject, (HttpServletRequest) request, (HttpServletResponse) response, chain); + } + /** * An exception indicating that cookies are present, but none of them contain a * valid JWT. @@ -595,4 +614,9 @@ private static class NoValidCookiesException extends Exception { super("None of the presented cookies are valid."); } } + + // Test seam: allows a mock/recording handler to be injected. + void setTokenExchangeHandler(TokenExchangeHandler tokenExchangeHandler) { + this.tokenExchangeHandler = tokenExchangeHandler; + } } 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..3c3e072334 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,16 @@ 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.UUID; +import java.util.stream.Collectors; public class SSOCookieFederationFilter extends AbstractJWTFilter { private static final JWTMessages LOGGER = MessagesFactory.get( JWTMessages.class ); @@ -103,6 +111,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 +347,28 @@ protected String constructLoginURL(HttpServletRequest request) { delimiter = "&"; } + final Set enabledFederatedOpConfigs = KnoxIDFUtils.fetchEnabledFederatedOpConfigs(request); + if (!enabledFederatedOpConfigs.isEmpty()) { + // A fresh random id per authorization flow. This value becomes the OIDC 'state' sent to the + // external OP and the key for the in-flight authorize/OP-config/nonce stores, so it must NOT be + // the HTTP session id: reusing JSESSIONID would (a) collide across concurrent flows in the same + // browser session (last-writer-wins on the shared stores) and (b) leak the session id to the OP + // via the state parameter (OP logs, URL bar, Referer). A per-flow UUID is unpredictable and + // unique, which is what 'state' is meant to be. + final String loginSessionId = UUID.randomUUID().toString(); + 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/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java new file mode 100644 index 0000000000..9fe7558f07 --- /dev/null +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java @@ -0,0 +1,202 @@ +/* + * 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.jwt.filter; + +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; +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.apache.knox.gateway.util.ServletRequestUtils; + +import javax.security.auth.Subject; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.security.Principal; +import java.text.ParseException; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.ACTOR_TOKEN_TYPE; +import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.SUBJECT_TOKEN_TYPE; +import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TOKEN_TYPE_ACCESS_TOKEN; +import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TOKEN_TYPE_JWT; +/** + * Handles RFC 8693 (OAuth 2.0 Token Exchange) requests on behalf of {@link JWTFederationFilter}. + * + *

The exchange parameters are sent in the {@code application/x-www-form-urlencoded} body and are + * therefore read from the unwrapped request (the filter chain wraps the request in a form + * that hides the body from {@code getParameter()}). The owning filter is used for JWT validation + * and for establishing the resulting security context.

+ * + *

Per RFC 8693 section 2.1: {@code subject_token} and {@code subject_token_type} are required; + * {@code actor_token} is optional, and {@code actor_token_type} is required when {@code actor_token} + * is present and must not be present otherwise. Only JWT-family token types are supported. When an + * {@code actor_token} is present the request is treated as delegation (on-behalf-of): the actor is + * the authenticated party and the subject is the impersonated party; otherwise the subject_token is + * simply exchanged for a token representing the subject.

+ */ +class TokenExchangeHandler { + + private final JWTFederationFilter filter; + + TokenExchangeHandler(JWTFederationFilter filter) { + this.filter = filter; + } + + /** + * Handle a token-exchange request that has already been identified by its grant type. + * + * @param request the HTTP request (wrapped; passed through to downstream processing) + * @param response the HTTP response + * @param chain the filter chain + * @throws IOException if an I/O error occurs + * @throws ServletException if a servlet error occurs + */ + void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + // The parameters live in the x-www-form-urlencoded body, which is only readable on the + // unwrapped request. The wrapped request is still used below so downstream processing is + // unchanged. + final HttpServletRequest bodyRequest = ServletRequestUtils.unwrapHttpServletRequest(request); + + final String subjectTokenValue = bodyRequest.getParameter(JWTFederationFilter.SUBJECT_TOKEN); + final String subjectTokenType = bodyRequest.getParameter(SUBJECT_TOKEN_TYPE); + final String actorTokenValue = bodyRequest.getParameter(JWTFederationFilter.ACTOR_TOKEN); + final String actorTokenType = bodyRequest.getParameter(ACTOR_TOKEN_TYPE); + final boolean hasActorToken = actorTokenValue != null && !actorTokenValue.isEmpty(); + final boolean hasActorTokenType = actorTokenType != null && !actorTokenType.isEmpty(); + + // RFC 8693 section 2.1: subject_token and subject_token_type are REQUIRED. + if (subjectTokenValue == null || subjectTokenValue.isEmpty()) { + filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, + "invalid_request: the subject_token parameter is required"); + return; + } + if (subjectTokenType == null || subjectTokenType.isEmpty()) { + filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, + "invalid_request: the subject_token_type parameter is required"); + return; + } + // RFC 8693 section 2.1: actor_token_type is REQUIRED when actor_token is present and MUST NOT + // be present otherwise. + if (hasActorToken && !hasActorTokenType) { + filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, + "invalid_request: actor_token_type is required when actor_token is present"); + return; + } + if (!hasActorToken && hasActorTokenType) { + filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, + "invalid_request: actor_token_type must not be present without actor_token"); + return; + } + // Only JWT-family token types are supported. + if (isNotSupportedTokenType(subjectTokenType)) { + filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, + "unsupported_token_type: unsupported subject_token_type " + subjectTokenType); + return; + } + if (hasActorToken && isNotSupportedTokenType(actorTokenType)) { + filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, + "unsupported_token_type: unsupported actor_token_type " + actorTokenType); + return; + } + + try { + final JWT subjectToken = filter.parseAndValidateJWT(request, response, chain, subjectTokenValue); + if (subjectToken == null) { + // Validation failed, error response already sent + return; + } + + final Subject subject; + if (hasActorToken) { + final JWT actorToken = filter.parseAndValidateJWT(request, response, chain, actorTokenValue); + if (actorToken == null) { + // Validation failed, error response already sent + return; + } + // Delegation (OBO): actor as PrimaryPrincipal, subject as the impersonated party + subject = createSubjectForTokenExchange(subjectToken, actorToken); + } else { + // No actor_token: exchange the subject_token for a token representing the subject itself + subject = filter.createSubjectFromToken(subjectToken); + } + + filter.continueWithEstablishedSecurityContext(subject, request, response, chain); + } catch (ParseException | UnknownTokenException e) { + filter.handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, + "Failed to parse token in token exchange: " + e.getMessage()); + } + } + + /** + * Token exchange only supports JWT-family token types. The access_token URN is accepted as an + * alias for jwt because Knox labels its issued (JWT) access tokens with that type. + * + * @param tokenType the RFC 8693 token type identifier + * @return true if the type does NOT map to a Knox JWT + */ + private boolean isNotSupportedTokenType(String tokenType) { + return !TOKEN_TYPE_JWT.equals(tokenType) && !TOKEN_TYPE_ACCESS_TOKEN.equals(tokenType); + } + + /** + * Create a Subject for a delegation (on-behalf-of) token exchange: the actor is the primary + * (authenticated) principal, and the subject is carried for the identity assertion layer, along + * with any pre-existing actor chain from the subject_token. + * + * @param subjectToken the validated subject token + * @param actorToken the validated actor token + * @return a Subject configured for token exchange + */ + private Subject createSubjectForTokenExchange(JWT subjectToken, JWT actorToken) { + final String subjectPrincipalName = subjectToken.getSubject(); + final String subjectIssuer = subjectToken.getIssuer(); + final String actorPrincipalName = actorToken.getSubject(); + final String actorIssuer = actorToken.getIssuer(); + + // PrimaryPrincipal is the ACTOR (the authenticated party) + final PrimaryPrincipal primaryPrincipal = new PrimaryPrincipal(actorPrincipalName); + + // TokenExchangePrincipal carries metadata for the identity assertion layer + final TokenExchangePrincipal tokenExchangePrincipal = + new TokenExchangePrincipalImpl(subjectPrincipalName, subjectIssuer, actorPrincipalName, actorIssuer); + + // Extract actor chain from subject_token (if present) using existing logic + final List> actorChain = TokenUtils.extractActorChain(subjectToken); + + final Set principals = new HashSet<>(); + principals.add(primaryPrincipal); + principals.add(tokenExchangePrincipal); + if (!actorChain.isEmpty()) { + principals.add(new ActorChainPrincipalImpl(actorChain)); + } + + @SuppressWarnings("rawtypes") + final HashSet emptySet = new HashSet(); + return new Subject(true, principals, emptySet, emptySet); + } +} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java index f025aa2773..16f71d959f 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java @@ -1520,4 +1520,7 @@ public byte[] getData() { } } + protected String passcodeVerificationCacheKey(final String tokenId, final String passcode) { + return tokenId + "::" + passcode; + } } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java index 2ea5524b9e..6c66d44a35 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTest.java @@ -195,7 +195,7 @@ private void testVerifyPasscodeTokens(String authTokenType, boolean tssEnabled) } EasyMock.replay(tokenStateService, tokenMetadata, request, response); - SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcode); + SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode)); final TestFilterChain chain = new TestFilterChain(); handler.doFilter(request, response, chain); 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..81e6249c85 --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java @@ -0,0 +1,879 @@ +/* + * 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.jwt.SignedJWT; +import org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter; +import org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter; + +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService; +import org.apache.knox.gateway.services.security.token.JWTokenAuthority; +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants; +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import java.net.URI; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; + +import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; + +/** + * Tests for two JWTFederationFilter extensions added for Knox IDF delegation. + * + *

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: 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 + * {@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); no actor token. The authority mock + * verifies the dynamic path calls verifyToken with the resolved JWKS URI, configured sig-alg, + * and type-verifier. + */ + @Test + public void testDynamicIssuerAllowedSubjectExternal() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", + new Date(System.currentTimeMillis() + 60000)); + + final Capture capturedDynamicJwt = EasyMock.newCapture(); + + final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.expect(mockAuth.verifyToken( + EasyMock.capture(capturedDynamicJwt), + EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))), + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) + .andReturn(true).once(); + EasyMock.replay(mockAuth); + ((TestJWTFederationFilter) handler).setTokenService(mockAuth); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once(); + EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertTrue("Filter chain should proceed", chain.doFilterCalled); + Assert.assertEquals(EXTERNAL_ISSUER, capturedDynamicJwt.getValue().getIssuer()); + EasyMock.verify(mockAuth, issuerSvc); + } + + /** + * Actor token from EXTERNAL_ISSUER (dynamic path); subject token from KNOX_ISSUER (static + * path). This is the primary K8s SA delegation scenario: the acting service carries a + * projected SA token with a dynamically registered issuer; the subject carries a Knox-issued + * token. The authority mock verifies the same argument contract as the previous test. + */ + @Test + public void testDynamicIssuerAllowedActorExternal() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT subjectJwt = getJWT(KNOX_ISSUER, "end-user", + new Date(System.currentTimeMillis() + 60000)); + final SignedJWT actorJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", + new Date(System.currentTimeMillis() + 60000)); + + final Capture 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 JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.expect(mockAuth.verifyToken( + EasyMock.anyObject(JWT.class), + EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))), + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) + .andReturn(false).once(); + EasyMock.replay(mockAuth); + ((TestJWTFederationFilter) handler).setTokenService(mockAuth); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once(); + EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse(chain.doFilterCalled); + EasyMock.verify(mockAuth, issuerSvc, response); + } + + /** + * Dynamic JWKS resolved, but the token is expired. The strict mock with {@code .times(0, 1)} + * allows JWKS signature verification to happen 0 or 1 times (validation order is not + * guaranteed), so the "Token has expired" rejection is the guaranteed outcome. If the JWKS + * call occurs, the captured JWT must have EXTERNAL_ISSUER. {@code verify(issuerSvc)} confirms + * the dynamic path was entered before the expiry check. + */ + @Test + public void testExpiredTokenRejectedOnDynamicPath() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT expiredJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", + new Date(System.currentTimeMillis() - 60000)); + + final Capture capturedJwt = EasyMock.newCapture(); + final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.expect(mockAuth.verifyToken( + EasyMock.capture(capturedJwt), + EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))), + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) + .andReturn(true).times(0, 1); + EasyMock.replay(mockAuth); + ((TestJWTFederationFilter) handler).setTokenService(mockAuth); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once(); + EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + expiredJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has expired"); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse(chain.doFilterCalled); + if (capturedJwt.hasCaptured()) { + Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); + } + EasyMock.verify(mockAuth, issuerSvc, response); + } + + /** + * Dynamic JWKS resolved, but the token's NotBefore is in the future. The strict mock with + * {@code .times(0, 1)} allows JWKS signature verification to happen 0 or 1 times (validation + * order is not guaranteed), so the "NotBefore check failed" rejection is the guaranteed + * outcome. If the JWKS call occurs, the captured JWT must have EXTERNAL_ISSUER. + */ + @Test + public void testFutureNbfRejectedOnDynamicPath() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final Date futureNbf = new Date(System.currentTimeMillis() + 300000); + final Date futureExpiry = new Date(System.currentTimeMillis() + 600000); + final SignedJWT nbfJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", futureExpiry, futureNbf, privateKey, "RS256"); + + final Capture capturedJwt = EasyMock.newCapture(); + final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.expect(mockAuth.verifyToken( + EasyMock.capture(capturedJwt), + EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))), + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) + .andReturn(true).times(0, 1); + EasyMock.replay(mockAuth); + ((TestJWTFederationFilter) handler).setTokenService(mockAuth); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once(); + EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + nbfJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: the NotBefore check failed"); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse(chain.doFilterCalled); + if (capturedJwt.hasCaptured()) { + Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); + } + EasyMock.verify(mockAuth, issuerSvc, response); + } + + /** + * Dynamic JWKS resolved, but the token's audience does not match the required audience. The + * strict mock with {@code .times(0, 1)} allows JWKS signature verification to happen 0 or 1 + * times (validation order is not guaranteed), so the audience rejection is the guaranteed + * outcome. If the JWKS call occurs, the captured JWT must have EXTERNAL_ISSUER. + */ + @Test + public void testAudienceMismatchRejectedOnDynamicPath() throws Exception { + final Properties props = getProperties(); + props.setProperty(JWTFederationFilter.KNOX_TOKEN_AUDIENCES, "required-audience"); + handler.init(new TestFilterConfig(props)); + + final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", + new Date(System.currentTimeMillis() + 60000)); // default aud="bar", not "required-audience" + + final Capture capturedJwt = EasyMock.newCapture(); + final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.expect(mockAuth.verifyToken( + EasyMock.capture(capturedJwt), + EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))), + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) + .andReturn(true).times(0, 1); + EasyMock.replay(mockAuth); + ((TestJWTFederationFilter) handler).setTokenService(mockAuth); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once(); + EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: missing required token audience"); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse(chain.doFilterCalled); + if (capturedJwt.hasCaptured()) { + Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); + } + EasyMock.verify(mockAuth, issuerSvc, response); + } + + // --------------------------------------------------------------------------- + // Token rejected — issuer does not qualify for dynamic JWKS verification + // --------------------------------------------------------------------------- + + /** + * The issuer is not registered in the dynamic registry; isDynamicJwks returns false. The + * filter rejects with 401. resolveJwksUri is not expected on the strict mock — any call to + * it would fail verify(), proving no HTTP fetch was attempted (SSRF prevention). + */ + @Test + public void testUntrustedIssuerRejectedNoHttpCall() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject", + new Date(System.currentTimeMillis() + 60000)); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(false).once(); + // resolveJwksUri not expected — any call fails verify(), proving no HTTP fetch attempted + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse(chain.doFilterCalled); + EasyMock.verify(issuerSvc, response); + } + + /** + * TrustedOidcIssuerService is null. The hook returns without calling any service method. + * EXTERNAL_ISSUER is not in expectedIssuers, so the filter rejects. + */ + @Test + public void testServiceUnavailable() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject", + new Date(System.currentTimeMillis() + 60000)); + + final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(null).anyTimes(); + EasyMock.replay(gws); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildServletContext(gws)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse(chain.doFilterCalled); + EasyMock.verify(response); + } + + /** + * Bearer JWT from EXTERNAL_ISSUER with no grant_type — not a token-exchange request. Because + * doFilter never marked this request as a token-exchange dispatch, resolveRegisteredIssuerJwks + * returns empty 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); + } + + /** + * Regression test for the grant_type query-param bypass: a plain Bearer JWT from a + * registry-registered dynamic-JWKS issuer, carrying a spoofed {@code grant_type=token-exchange} + * request parameter (as HttpServletRequest.getParameter would surface from the URL query string). + * Because a Bearer header is present, getWireToken routes this down the JWT branch — not the + * token-exchange branch — so doFilter never sets TOKEN_EXCHANGE_REQUEST_ATTR. The old code checked + * getParameter(GRANT_TYPE) here and would have resolved the registry JWKS and accepted the token; + * the attribute-based guard rejects it. The strict issuer service (no expectations) proves the + * registry is never even consulted. + */ + @Test + public void testSpoofedGrantTypeQueryParamDoesNotUnlockRegistry() 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(); + // Attacker-controlled query parameter: getParameter merges query string and body, so this is + // what a ?grant_type= on the request URL would look like to the filter. + EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).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("Spoofed grant_type query param must not authenticate the token", chain.doFilterCalled); + EasyMock.verify(strictIssuerSvc); + } + + // --------------------------------------------------------------------------- + // Dynamic registry path — discovered JWKS URI must be HTTPS (OOTB) + // --------------------------------------------------------------------------- + + private static final String INSECURE_JWKS_URI = "http://external.oidc.example.com/.well-known/jwks.json"; + + /** + * OOTB (knox.token.exchange.dynamic.jwks.allow.http unset) a non-HTTPS JWKS URI resolved via + * dynamic discovery for a registered issuer must be rejected: fetching signing keys over cleartext + * would let an on-path attacker substitute keys and forge subject tokens. The registry is consulted + * (isDynamicJwks + resolveJwksUri), but the strict authority mock with no expectations proves + * verifyToken is never called against the insecure URI, and the request is rejected (401). + */ + @Test + public void testInsecureDynamicJwksUriRejectedByDefault() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", + new Date(System.currentTimeMillis() + 60000)); + + final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.replay(mockAuth); // no expectations: verifyToken must never be reached + ((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(INSECURE_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertFalse("Insecure (non-HTTPS) dynamic JWKS URI must be rejected OOTB", chain.doFilterCalled); + EasyMock.verify(mockAuth, issuerSvc); + } + + /** + * Opt-in bypass: with knox.token.exchange.dynamic.jwks.allow.http=true on the provider, an http + * JWKS URI resolved via dynamic discovery is accepted and used exclusively for signature + * verification (e.g. an internal test OP). Mirrors testDynamicIssuerAllowedSubjectExternal but with + * the insecure URI and the toggle enabled. + */ + @Test + public void testInsecureDynamicJwksUriAllowedWhenConfigured() throws Exception { + final Properties props = getProperties(); + props.put(JWTFederationFilter.TOKEN_EXCHANGE_DYNAMIC_JWKS_ALLOW_HTTP, "true"); + handler.init(new TestFilterConfig(props)); + + final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", + new Date(System.currentTimeMillis() + 60000)); + + final Capture capturedDynamicJwt = EasyMock.newCapture(); + + final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class); + EasyMock.expect(mockAuth.verifyToken( + EasyMock.capture(capturedDynamicJwt), + EasyMock.eq(Set.of(new URI(INSECURE_JWKS_URI))), + EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), + EasyMock.isA(JOSEObjectTypeVerifier.class))) + .andReturn(true).once(); + EasyMock.replay(mockAuth); + ((TestJWTFederationFilter) handler).setTokenService(mockAuth); + + final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once(); + EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(INSECURE_JWKS_URI)).once(); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(request, response, issuerSvc); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertTrue("allow.http=true must permit the http JWKS URI", chain.doFilterCalled); + Assert.assertEquals(EXTERNAL_ISSUER, capturedDynamicJwt.getValue().getIssuer()); + EasyMock.verify(mockAuth, issuerSvc); + } + + // --------------------------------------------------------------------------- + // 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 + // --------------------------------------------------------------------------- + + /** + * Token-exchange request with a Knox-issuer (static) subject token and no actor token. The + * strict issuerSvc mock with no expectations proves isDynamicJwks is never called for a + * static-issuer token, even in a token-exchange grant. + */ + @Test + public void testTokenExchangeWithStaticIssuerSubjectSucceeds() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT subjectJwt = getJWT(KNOX_ISSUER, "some-user", + new Date(System.currentTimeMillis() + 60000)); + + final TrustedOidcIssuerService strictIssuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.replay(strictIssuerSvc); + + final HttpServletRequest request = buildTokenExchangeRequest( + subjectJwt.serialize(), buildContextWithIssuerService(strictIssuerSvc)); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(request, response); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertTrue("Filter chain should proceed", chain.doFilterCalled); + EasyMock.verify(strictIssuerSvc); + } + + /** + * Bearer JWT from KNOX_ISSUER (in static expectedIssuers). validateToken() returns from the + * static-issuer branch before resolveRegisteredIssuerJwks is reached. The strict issuerSvc + * mock with no expectations proves the hook was not called: any service method call would + * throw immediately. + */ + @Test + public void testNonTokenExchangeGrantUnaffected() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user", + new Date(System.currentTimeMillis() + 60000)); + + final TrustedOidcIssuerService strictIssuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class); + EasyMock.replay(strictIssuerSvc); + + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(request.getHeader("Authorization")) + .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize()).anyTimes(); + EasyMock.expect(request.getServletContext()) + .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes(); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(request, response); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertTrue(chain.doFilterCalled); + EasyMock.verify(strictIssuerSvc); + } + + // --------------------------------------------------------------------------- + // TOKEN_ISS_ATTRIBUTE — separate concern from JWKS logic + // --------------------------------------------------------------------------- + + /** + * After successful Bearer JWT validation, addKnoxIDFAttributes() stores TOKEN_ISS_ATTRIBUTE + * on the request. Used by admin endpoint handlers for per-cluster scope limiting. + */ + @Test + public void testIssAttributeSetAfterValidation() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user", + new Date(System.currentTimeMillis() + 60000)); + + final Map capturedAttrs = new HashMap<>(); + final HttpServletRequest underlying = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(underlying.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(underlying.getHeader("Authorization")) + .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize()).anyTimes(); + EasyMock.replay(underlying); + + final HttpServletRequest request = new HttpServletRequestWrapper(underlying) { + @Override + public void setAttribute(String name, Object o) { + capturedAttrs.put(name, o); + } + + @Override + public Object getAttribute(String name) { + return capturedAttrs.get(name); + } + }; + + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(response); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + Assert.assertTrue(chain.doFilterCalled); + Assert.assertEquals(KNOX_ISSUER, capturedAttrs.get(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE)); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private ServletContext buildContextWithIssuerService(TrustedOidcIssuerService issuerSvc) { + final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(issuerSvc).anyTimes(); + EasyMock.replay(gws); + return buildServletContext(gws); + } + + private ServletContext buildServletContext(GatewayServices gws) { + final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(gws).anyTimes(); + EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE)) + .andReturn("jwt-test-topology").anyTimes(); + EasyMock.replay(ctx); + return ctx; + } + + private HttpServletRequest buildTokenExchangeRequest(String subjectToken, ServletContext ctx) { + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE)) + .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes(); + // ACTOR_TOKEN not mocked — niceMock returns null, making actor_token absent + EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes(); + mockRequestAttributeStore(request); + return request; + } + + private HttpServletRequest buildTokenExchangeRequest(String subjectToken, String actorToken, + ServletContext ctx) { + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE)) + .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(actorToken).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE)) + .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes(); + EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes(); + mockRequestAttributeStore(request); + return request; + } + + /** + * Makes {@code getAttribute}/{@code setAttribute} behave like a real attribute map on an EasyMock + * nice mock. doFilter marks a genuine token-exchange dispatch by setting + * {@code TOKEN_EXCHANGE_REQUEST_ATTR}, and resolveRegisteredIssuerJwks now reads that attribute + * (rather than the spoofable grant_type request parameter), so the mock must round-trip it. + */ + private static void mockRequestAttributeStore(final HttpServletRequest request) { + final Map attrs = new HashMap<>(); + request.setAttribute(EasyMock.anyString(), EasyMock.anyObject()); + EasyMock.expectLastCall().andAnswer(() -> { + attrs.put((String) EasyMock.getCurrentArguments()[0], EasyMock.getCurrentArguments()[1]); + return null; + }).anyTimes(); + EasyMock.expect(request.getAttribute(EasyMock.anyString())) + .andAnswer(() -> attrs.get(EasyMock.getCurrentArguments()[0])).anyTimes(); + } + +} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java index 515d173f3c..3c635d50e0 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java @@ -42,9 +42,11 @@ import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_CREDENTIALS; import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_ID; import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.security.CommonTokenConstants.AUTH_CODE; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import javax.servlet.http.HttpServletRequestWrapper; @@ -278,7 +280,7 @@ public void testVerifyClientCredentialsFlow() throws Exception { // Wrap the request to simulate real-world scenario where wrappers hide parameter access final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); - SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcode); + SignatureVerificationCache.getInstance(topologyName, filterConfig).recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode)); final TestFilterChain chain = new TestFilterChain(); handler.doFilter(request, response, chain); @@ -387,16 +389,87 @@ public void testInvalidPasscodeForJWT() throws Exception { public void testUnableToParseJWT() throws Exception { } + @Override @Test - public void testGetWireTokenUsingRefreshTokenFlow() throws Exception { - final String refreshToken = "WTJ4cFpXNTBMV2xrTFRFeU16UTE6OlkyeHBaVzUwTFhObFkzSmxkQzB4TWpNME5RPT0="; - testGetWireTokenWithGrant(JWTFederationFilter.REFRESH_TOKEN, JWTFederationFilter.REFRESH_TOKEN_PARAM, refreshToken); + public void testPasscodeCannotBeReplayedAgainstDifferentTokenId() { } @Test - public void testGetWireTokenUsingTokenExchangeFlow() throws Exception { - final String subjectToken = "WTJ4cFpXNTBMV2xrTFRFeU16UTE2OlkyeHBaVzUwTFhObFkzSmxkQzB4TWpNME5RPT0="; - testGetWireTokenWithGrant(JWTFederationFilter.TOKEN_EXCHANGE, JWTFederationFilter.SUBJECT_TOKEN, subjectToken); + public void testGetWireTokenUsingAuthorizationCodeFlowWithoutClientSecret() throws Exception { + // A public client redeeming an authorization code with PKCE sends grant_type=authorization_code + // with no Authorization header and no client_secret. Unlike client_credentials, the filter must + // NOT reject this; it flags TokenType.AuthCode so the request is forwarded to the KnoxIDF token + // endpoint, which authenticates the caller via the code_verifier. + final HttpServletRequest mockRequest = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(mockRequest.getHeader("Authorization")).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getParameter(GRANT_TYPE)).andReturn(AUTH_CODE).anyTimes(); + EasyMock.replay(mockRequest); + + // Wrap the request to simulate real-world scenario where wrappers hide parameter access + final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); + + handler.init(new TestFilterConfig(getProperties())); + final Pair wireToken = ((TestJWTFederationFilter) handler).getWireToken(request); + + EasyMock.verify(mockRequest); + + assertNotNull(wireToken); + assertEquals(TokenType.AuthCode, wireToken.getLeft()); + assertNull(wireToken.getRight()); + } + + @Test + public void testGetWireTokenUsingAuthorizationCodeFlowDoesNotParseClientSecret() throws Exception { + // Even when a (confidential) client includes a client_secret on the authorization_code grant, + // the filter routes it through the AuthCode pass-through and does NOT try to parse the secret as + // a passcode here -- the token endpoint validates it. A non-passcode-formatted secret that would + // have triggered INVALID_CLIENT_SECRET on the client_credentials path must not do so here. + final HttpServletRequest mockRequest = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(mockRequest.getHeader("Authorization")).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getParameter(GRANT_TYPE)).andReturn(AUTH_CODE).anyTimes(); + EasyMock.expect(mockRequest.getParameter(CLIENT_SECRET)).andReturn("not-a-passcode").anyTimes(); + EasyMock.replay(mockRequest); + + // Wrap the request to simulate real-world scenario where wrappers hide parameter access + final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); + + handler.init(new TestFilterConfig(getProperties())); + final Pair wireToken = ((TestJWTFederationFilter) handler).getWireToken(request); + + assertNotNull(wireToken); + assertEquals(TokenType.AuthCode, wireToken.getLeft()); + assertNull(wireToken.getRight()); + } + + @Test + public void testAuthorizationCodeFlowForwardsToServiceWithoutClientSecret() throws Exception { + // End-to-end at the filter level: a public PKCE client's authorization_code request is forwarded + // down the chain with an anonymous subject rather than rejected, so the KnoxIDF token endpoint can + // validate the code + code_verifier and issue the token. + final HttpServletRequest mockRequest = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(mockRequest.getHeader("Authorization")).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(mockRequest.getParameter(GRANT_TYPE)).andReturn(AUTH_CODE).anyTimes(); + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(mockRequest, response); + + // Wrap the request to simulate real-world scenario where wrappers hide parameter access + final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); + + handler.init(new TestFilterConfig(getProperties())); + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + + assertTrue(chain.doFilterCalled); + Assert.assertNotNull(chain.subject); + } + + @Test + public void testGetWireTokenUsingRefreshTokenFlow() throws Exception { + final String refreshToken = "WTJ4cFpXNTBMV2xrTFRFeU16UTE6OlkyeHBaVzUwTFhObFkzSmxkQzB4TWpNME5RPT0="; + testGetWireTokenWithGrant(JWTFederationFilter.REFRESH_TOKEN, JWTFederationFilter.REFRESH_TOKEN_PARAM, refreshToken); } @Test @@ -407,14 +480,6 @@ public void testVerifyRefreshTokenFlow() throws Exception { testVerifyTokenWithGrant(tokenId, passcode, passcodeToken, JWTFederationFilter.REFRESH_TOKEN, JWTFederationFilter.REFRESH_TOKEN_PARAM); } - @Test - public void testVerifyTokenExchangeFlow() throws Exception { - final String tokenId = "4e0c548b-6568-4061-a3dc-62908087650c"; - final String passcode = "0138aaed-ca2a-47f1-8ed8-e0c397596f97"; - final String passcodeToken = "TkdVd1l6VTBPR0l0TmpVMk9DMDBNRFl4TFdFelpHTXROakk1TURnd09EYzJOVEJqOjpNREV6T0dGaFpXUXRZMkV5WVMwME4yWXhMVGhsWkRndFpUQmpNemszTlRrMlpqazM="; - testVerifyTokenWithGrant(tokenId, passcode, passcodeToken, JWTFederationFilter.TOKEN_EXCHANGE, JWTFederationFilter.SUBJECT_TOKEN); - } - private Pair createMockTokenStateService(String tokenId, String passcodeToken) throws UnknownTokenException { final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class); EasyMock.expect(tokenStateService.getTokenExpiration(tokenId)).andReturn(Long.MAX_VALUE).anyTimes(); @@ -484,7 +549,7 @@ private void testVerifyTokenWithGrant(String tokenId, String passcode, String pa // Wrap the request to simulate real-world scenario where wrappers hide parameter access final HttpServletRequest request = new TestServletRequestWrapper(mockRequest); - SignatureVerificationCache.getInstance("jwt-topology", filterConfig).recordSignatureVerification(passcode); + SignatureVerificationCache.getInstance("jwt-topology", filterConfig).recordSignatureVerification(passcodeVerificationCacheKey(tokenId, passcode)); final TestFilterChain chain = new TestFilterChain(); handler.doFilter(request, response, chain); 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-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java index b92158f89f..dadd41cffe 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/TokenIDAsHTTPBasicCredsFederationFilterTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.fail; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.time.Instant; @@ -265,6 +266,55 @@ public void testInvalidPasscodeForJWT() throws Exception { } } + @Test + public void testPasscodeCannotBeReplayedAgainstDifferentTokenId() throws Exception { + Properties props = getProperties(); + handler.init(new TestFilterConfig(props)); + + final long issueTime = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5); + final Date expiry = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5)); + + final SignedJWT attackerJwt = getJWT(AbstractJWTFilter.JWT_DEFAULT_ISSUER, "attacker", expiry, privateKey); + final String attackerPasscode = (String) attackerJwt.getJWTClaimsSet().getClaims().get(PASSCODE_CLAIM); + addTokenState(attackerJwt, issueTime, "attacker", attackerPasscode); + + final SignedJWT victimJwt = getJWT(AbstractJWTFilter.JWT_DEFAULT_ISSUER, "bob", expiry, privateKey); + final String victimPasscode = (String) victimJwt.getJWTClaimsSet().getClaims().get(PASSCODE_CLAIM); + addTokenState(victimJwt, issueTime, "bob", victimPasscode); + final String victimTokenId = getTokenId(victimJwt); + + final TestFilterChain seedChain = new TestFilterChain(); + handler.doFilter(newPasscodeRequest(generatePasscodeField(getTokenId(attackerJwt), attackerPasscode)), + newResponse(), seedChain); + Assert.assertTrue("Precondition: the attacker's own passcode should authenticate.", seedChain.doFilterCalled); + + final TestFilterChain attackChain = new TestFilterChain(); + handler.doFilter(newPasscodeRequest(generatePasscodeField(victimTokenId, attackerPasscode)), + newResponse(), attackChain); + + Assert.assertFalse("A passcode must not authenticate when paired with a different token id " + + "(identity-assertion / authentication bypass).", attackChain.doFilterCalled); + Assert.assertNull("No subject should have been established for the replayed passcode.", attackChain.getSubject()); + } + + private HttpServletRequest newPasscodeRequest(final String passcodeField) { + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + setTokenOnRequest(request, JWTFederationFilter.PASSCODE, passcodeField); + EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(request.getPathInfo()).andReturn("resource").anyTimes(); + EasyMock.expect(request.getQueryString()).andReturn(null).anyTimes(); + EasyMock.replay(request); + return request; + } + + private HttpServletResponse newResponse() throws IOException { + final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.expect(response.encodeRedirectURL(SERVICE_URL)).andReturn(SERVICE_URL).anyTimes(); + EasyMock.expect(response.getOutputStream()).andAnswer(DummyServletOutputStream::new).anyTimes(); + EasyMock.replay(response); + return response; + } + @Override public void testJWTWithoutKnoxUUIDClaim() throws Exception { // Override to disable N/A test diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java new file mode 100644 index 0000000000..1dad10b28c --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.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.provider.federation.jwt.filter; + +import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * Verifies that {@link JWTFederationFilter#doFilter} routes RFC 8693 token-exchange requests to the + * {@link TokenExchangeHandler}. The handler itself is replaced by a recording stub so this test + * only asserts the dispatch decision (the handler's own logic is covered by + * {@link TokenExchangeHandlerTest}). + * + *

The grant type lives in the {@code x-www-form-urlencoded} body, which is only visible on the + * unwrapped request; {@link BodyHidingRequestWrapper} reproduces that (its {@code getParameter} + * returns {@code null}, mirroring the production wrapper).

+ */ +public class JWTFederationFilterTokenExchangeRoutingTest { + + private JWTFederationFilter filter; + private RecordingTokenExchangeHandler recordingHandler; + private HttpServletResponse response; + private RecordingFilterChain chain; + + @Before + public void setUp() { + filter = new JWTFederationFilter(); + recordingHandler = new RecordingTokenExchangeHandler(filter); + filter.setTokenExchangeHandler(recordingHandler); + response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.replay(response); + chain = new RecordingFilterChain(); + } + + @Test + public void testTokenExchangeGrantRoutesToHandler() throws Exception { + // grant_type in the body (unwrapped), no Authorization header + final HttpServletRequest request = wrapped(bodyRequest(JWTFederationFilter.TOKEN_EXCHANGE, null)); + + filter.doFilter(request, response, chain); + + assertTrue("token-exchange grant should be dispatched to the handler", recordingHandler.called); + assertFalse("the filter chain must not continue when the handler takes over", chain.called); + } + + @Test + public void testNonTokenExchangeGrantDoesNotRouteToHandler() throws Exception { + // no grant_type at all -> not a token exchange + final HttpServletRequest request = wrapped(bodyRequest(null, null)); + + filter.doFilter(request, response, chain); + + assertFalse("non-exchange requests must not reach the handler", recordingHandler.called); + } + + private static HttpServletRequest wrapped(HttpServletRequest inner) { + return new BodyHidingRequestWrapper(inner); + } + + private static HttpServletRequest bodyRequest(String grantType, String authorizationHeader) { + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getHeader("Authorization")).andReturn(authorizationHeader).anyTimes(); + EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(grantType).anyTimes(); + EasyMock.expect(request.getQueryString()).andReturn(null).anyTimes(); + EasyMock.expect(request.getPathInfo()).andReturn(null).anyTimes(); + EasyMock.replay(request); + return request; + } + + /** Mirrors the production request wrapper: parameters are hidden, the body is only on getRequest(). */ + private static final class BodyHidingRequestWrapper extends HttpServletRequestWrapper { + private final HttpServletRequest inner; + + BodyHidingRequestWrapper(HttpServletRequest inner) { + super(inner); + this.inner = inner; + } + + @Override + public String getParameter(String name) { + return null; // hide body parameters + } + + @Override + public String getHeader(String name) { + return inner.getHeader(name); + } + + @Override + public HttpServletRequest getRequest() { + return inner; + } + } + + private static final class RecordingTokenExchangeHandler extends TokenExchangeHandler { + private boolean called; + + RecordingTokenExchangeHandler(JWTFederationFilter filter) { + super(filter); + } + + @Override + void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { + this.called = true; + } + } + + private static final class RecordingFilterChain implements FilterChain { + private boolean called; + + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { + this.called = true; + } + } +} diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java new file mode 100644 index 0000000000..a5296b3490 --- /dev/null +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java @@ -0,0 +1,263 @@ +/* + * 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.jwt.filter; + +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 org.apache.knox.gateway.security.ActorChainPrincipal; +import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.apache.knox.gateway.security.TokenExchangePrincipal; +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.apache.knox.gateway.services.security.token.impl.JWTToken; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +import javax.security.auth.Subject; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.text.ParseException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Unit tests for {@link TokenExchangeHandler} covering the RFC 8693 request-validation and + * subject-construction business logic. The owning {@link JWTFederationFilter}'s callbacks + * (JWT validation and security-context establishment) are stubbed by {@link RecordingFilter}. + */ +public class TokenExchangeHandlerTest { + + private static final String JWT_TYPE = JWTFederationFilter.TOKEN_TYPE_JWT; + private static final String ACCESS_TOKEN_TYPE = JWTFederationFilter.TOKEN_TYPE_ACCESS_TOKEN; + private static final String SAML2_TYPE = "urn:ietf:params:oauth:token-type:saml2"; + + private RecordingFilter filter; + private TokenExchangeHandler handler; + private HttpServletResponse response; + private FilterChain chain; + + @Before + public void setUp() { + filter = new RecordingFilter(); + handler = new TokenExchangeHandler(filter); + response = EasyMock.createNiceMock(HttpServletResponse.class); + chain = EasyMock.createNiceMock(FilterChain.class); + EasyMock.replay(response, chain); + } + + @Test + public void testSubjectTokenRequired() throws Exception { + handler.handle(request(null, JWT_TYPE, null, null), response, chain); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertTrue(filter.errorMessage.contains("subject_token")); + assertFalse(filter.continued); + } + + @Test + public void testSubjectTokenTypeRequired() throws Exception { + handler.handle(request("subtok", null, null, null), response, chain); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertTrue(filter.errorMessage.contains("subject_token_type")); + assertFalse(filter.continued); + } + + @Test + public void testActorTokenTypeRequiredWhenActorPresent() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(request("subtok", JWT_TYPE, "acttok", null), response, chain); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertTrue(filter.errorMessage.contains("actor_token_type is required")); + assertFalse(filter.continued); + } + + @Test + public void testActorTokenTypeForbiddenWithoutActor() throws Exception { + handler.handle(request("subtok", JWT_TYPE, null, JWT_TYPE), response, chain); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertTrue(filter.errorMessage.contains("must not be present")); + assertFalse(filter.continued); + } + + @Test + public void testUnsupportedSubjectTokenType() throws Exception { + handler.handle(request("subtok", SAML2_TYPE, null, null), response, chain); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertTrue(filter.errorMessage.contains("unsupported_token_type")); + assertFalse(filter.continued); + } + + @Test + public void testUnsupportedActorTokenType() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(request("subtok", JWT_TYPE, "acttok", SAML2_TYPE), response, chain); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertTrue(filter.errorMessage.contains("unsupported_token_type")); + assertFalse(filter.continued); + } + + @Test + public void testAccessTokenTypeIsAcceptedAsJwt() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(request("subtok", ACCESS_TOKEN_TYPE, null, null), response, chain); + // access_token URN is accepted (no unsupported_token_type error) and the exchange proceeds + assertEquals(-1, filter.errorStatus); + assertTrue(filter.continued); + } + + @Test + public void testSubjectOnlyExchangeEstablishesSubjectIdentity() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(request("subtok", JWT_TYPE, null, null), response, chain); + + assertTrue(filter.continued); + assertNotNull(filter.establishedSubject); + // Plain subject exchange: subject is the primary identity, no delegation principal + assertEquals("alice", primaryName(filter.establishedSubject)); + assertTrue(filter.establishedSubject.getPrincipals(TokenExchangePrincipal.class).isEmpty()); + } + + @Test + public void testDelegationExchangeMakesActorPrimaryWithTokenExchangePrincipal() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + filter.valid.put("acttok", jwt("svc-dataservice", "https://k8s")); + handler.handle(request("subtok", JWT_TYPE, "acttok", JWT_TYPE), response, chain); + + assertTrue(filter.continued); + assertNotNull(filter.establishedSubject); + // OBO: the actor is the authenticated (primary) party ... + assertEquals("svc-dataservice", primaryName(filter.establishedSubject)); + // ... and a TokenExchangePrincipal carries the subject/actor metadata + final TokenExchangePrincipal tep = + filter.establishedSubject.getPrincipals(TokenExchangePrincipal.class).iterator().next(); + assertEquals("alice", tep.getSubjectPrincipalName()); + assertEquals("svc-dataservice", tep.getActorPrincipalName()); + } + + @Test + public void testSubjectTokenWithActClaimCreatesActorChainPrincipal() throws Exception { + // subject_token already carries a prior delegation chain (an 'act' claim) ... + filter.valid.put("subtok", jwtWithActClaim("alice", "KNOXSSO", Map.of("sub", "prior-actor"))); + filter.valid.put("acttok", jwt("svc-dataservice", "https://k8s")); + handler.handle(request("subtok", JWT_TYPE, "acttok", JWT_TYPE), response, chain); + + assertTrue(filter.continued); + assertNotNull(filter.establishedSubject); + // ... which is preserved as an ActorChainPrincipal in the exchanged Subject + final Set actorChainPrincipals = + filter.establishedSubject.getPrincipals(ActorChainPrincipal.class); + assertFalse("ActorChainPrincipal should be present", actorChainPrincipals.isEmpty()); + assertEquals("prior-actor", actorChainPrincipals.iterator().next().getCurrentActor()); + } + + @Test + public void testSubjectValidationFailureDoesNotEstablishContext() throws Exception { + // "subtok" is not in the valid map -> parseAndValidateJWT returns null (error already sent) + handler.handle(request("subtok", JWT_TYPE, null, null), response, chain); + assertFalse(filter.continued); + } + + @Test + public void testActorValidationFailureDoesNotEstablishContext() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + // "acttok" is not valid + handler.handle(request("subtok", JWT_TYPE, "acttok", JWT_TYPE), response, chain); + assertFalse(filter.continued); + } + + private static String primaryName(Subject subject) { + return subject.getPrincipals(PrimaryPrincipal.class).iterator().next().getName(); + } + + private HttpServletRequest request(String subjectToken, String subjectTokenType, + String actorToken, String actorTokenType) { + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE)).andReturn(subjectTokenType).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(actorToken).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE)).andReturn(actorTokenType).anyTimes(); + EasyMock.replay(request); + return request; + } + + private static JWT jwt(String subject, String issuer) { + final JWT jwt = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwt.getSubject()).andReturn(subject).anyTimes(); + EasyMock.expect(jwt.getIssuer()).andReturn(issuer).anyTimes(); + // no actor chain in the token + EasyMock.expect(jwt.getClaimAsObject(EasyMock.anyString())).andReturn(null).anyTimes(); + EasyMock.replay(jwt); + return jwt; + } + + private static JWT jwtWithActClaim(String subject, String issuer, Map actClaim) { + final JWT jwt = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(jwt.getSubject()).andReturn(subject).anyTimes(); + EasyMock.expect(jwt.getIssuer()).andReturn(issuer).anyTimes(); + // subject_token carries a prior delegation chain via its 'act' claim + EasyMock.expect(jwt.getClaimAsObject(JWTToken.ACT_CLAIM)).andReturn(actClaim).anyTimes(); + EasyMock.replay(jwt); + return jwt; + } + + /** + * A {@link JWTFederationFilter} whose validation and context-establishment callbacks are + * replaced with recording stubs, so the handler's own logic can be exercised in isolation. + */ + private static final class RecordingFilter extends JWTFederationFilter { + private final Map valid = new HashMap<>(); + private int errorStatus = -1; + private String errorMessage; + private boolean continued; + private Subject establishedSubject; + + @Override + JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse response, + FilterChain chain, String tokenValue) + throws ParseException, IOException, ServletException { + return valid.get(tokenValue); + } + + @Override + protected Subject createSubjectFromToken(final JWT token) { + final Subject subject = new Subject(); + subject.getPrincipals().add(new PrimaryPrincipal(token.getSubject())); + return subject; + } + + @Override + protected void continueWithEstablishedSecurityContext(Subject subject, HttpServletRequest request, + HttpServletResponse response, FilterChain chain) { + this.continued = true; + this.establishedSubject = subject; + } + + @Override + protected void handleValidationError(HttpServletRequest request, HttpServletResponse response, + int status, String error) { + this.errorStatus = status; + this.errorMessage = error == null ? "" : error; + } + } +} diff --git a/gateway-provider-security-shiro/pom.xml b/gateway-provider-security-shiro/pom.xml index 8cce8d339b..ae07dbd4d8 100644 --- a/gateway-provider-security-shiro/pom.xml +++ b/gateway-provider-security-shiro/pom.xml @@ -67,6 +67,22 @@ org.apache.shiro shiro-web + + org.apache.shiro + shiro-cache + + + org.apache.shiro + shiro-crypto-core + + + org.apache.shiro + shiro-crypto-hash + + + org.apache.shiro + shiro-lang + org.ehcache 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..bffc5049af 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/filter/RedirectToUrlFilter.java @@ -27,6 +27,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; import org.apache.knox.gateway.config.GatewayConfig; public class RedirectToUrlFilter extends AbstractGatewayFilter { @@ -47,7 +48,9 @@ public 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) { + // Treat a blank fedOpSid the same as absent: a bare "?fedOpSid=" must not suppress the redirect. + // (Downstream authentication still runs; this only prevents an empty param from skipping it.) + if (redirectUrl != null && request.getHeader("Authorization") == null && StringUtils.isBlank(request.getParameter("fedOpSid"))) { response.sendRedirect(redirectUrl + getOriginalQueryString(request)); } chain.doFilter(request, response); diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxCacheManager.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxCacheManager.java index 0d39fdf70c..7aee9d7068 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxCacheManager.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxCacheManager.java @@ -20,12 +20,12 @@ import org.apache.knox.gateway.ShiroMessages; import org.apache.knox.gateway.ehcache.EhcacheShiro; import org.apache.knox.gateway.i18n.messages.MessagesFactory; -import org.apache.shiro.ShiroException; +import org.apache.shiro.lang.ShiroException; import org.apache.shiro.cache.Cache; import org.apache.shiro.cache.CacheException; -import org.apache.shiro.io.ResourceUtils; -import org.apache.shiro.util.Destroyable; -import org.apache.shiro.util.Initializable; +import org.apache.shiro.lang.io.ResourceUtils; +import org.apache.shiro.lang.util.Destroyable; +import org.apache.shiro.lang.util.Initializable; import org.ehcache.CacheManager; import org.ehcache.StateTransitionException; import org.ehcache.Status; diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealm.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealm.java index 8b9f78d1a5..ff5c0956df 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealm.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealm.java @@ -36,16 +36,15 @@ import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; -import org.apache.shiro.crypto.hash.DefaultHashService; -import org.apache.shiro.crypto.hash.Hash; -import org.apache.shiro.crypto.hash.HashRequest; -import org.apache.shiro.crypto.hash.HashService; +import org.apache.shiro.crypto.SecureRandomNumberGenerator; +import org.apache.shiro.crypto.hash.SimpleHash; import org.apache.shiro.realm.ldap.DefaultLdapRealm; import org.apache.shiro.realm.ldap.LdapContextFactory; import org.apache.shiro.realm.ldap.LdapUtils; import org.apache.shiro.subject.MutablePrincipalCollection; import org.apache.shiro.subject.PrincipalCollection; -import org.apache.shiro.util.StringUtils; +import org.apache.shiro.lang.util.ByteSource; +import org.apache.shiro.lang.util.StringUtils; import javax.naming.AuthenticationException; import javax.naming.Context; @@ -61,6 +60,7 @@ import javax.naming.ldap.LdapName; import javax.naming.ldap.PagedResultsControl; import javax.naming.ldap.PagedResultsResponseControl; +import javax.naming.ldap.Rdn; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -151,6 +151,18 @@ public class KnoxLdapRealm extends DefaultLdapRealm { private static final String HASHING_ALGORITHM = "SHA-256"; + /* + * KNOX-3421: Shiro 2.x's DefaultHashService applies a large per-algorithm default + * iteration count (e.g. 50000 for SHA-256), whereas HashedCredentialsMatcher still + * defaults to a single iteration. To keep the credential round-trip consistent under + * the SHA-256 scheme, the stored hash is computed here with an explicit salt and this + * pinned iteration count, which matches the matcher configured in the constructor. + */ + private static final int HASHING_ITERATIONS = 1; + + /** How a substituted template value must be escaped for its target context. */ + private enum EscapeMode { NONE, FILTER, DN } + static { SUBTREE_SCOPE.setSearchScope(SearchControls.SUBTREE_SCOPE); ONELEVEL_SCOPE.setSearchScope(SearchControls.ONELEVEL_SCOPE); @@ -186,10 +198,9 @@ public class KnoxLdapRealm extends DefaultLdapRealm { private String userSearchAttributeName; private String userObjectClass = "person"; - private HashService hashService = new DefaultHashService(); - public KnoxLdapRealm() { HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher(HASHING_ALGORITHM); + credentialsMatcher.setHashIterations(HASHING_ITERATIONS); setCredentialsMatcher(credentialsMatcher); } @@ -258,7 +269,7 @@ private Set rolesFor(PrincipalCollection principals, final String userNa String userDn; if (userSearchAttributeName == null || userSearchAttributeName.isEmpty()) { // memberAttributeValuePrefix and memberAttributeValueSuffix were computed from memberAttributeValueTemplate - userDn = memberAttributeValuePrefix + userName + memberAttributeValueSuffix; + userDn = memberAttributeValuePrefix + escapeDnValue(userName) + memberAttributeValueSuffix; } else { userDn = getUserDn(userName); } @@ -684,13 +695,19 @@ protected String getUserDn( final String principal ) throws IllegalArgumentExcep ( userSearchAttributeName == null && userSearchFilter == null && !"object".equalsIgnoreCase( userSearchScope ) ) ) { - userDn = expandTemplate( userDnTemplate, matchedPrincipal ); + // When the template is exactly "{0}" the principal IS the full DN (e.g. a system + // bind DN), so DN-escaping it would corrupt the DN and break the bind. Escaping is + // only needed when the placeholder is embedded within surrounding DN structure + // (e.g. "uid={0},ou=people,..."), where the principal is a single RDN value that + // could otherwise inject additional RDNs. + final EscapeMode escapeMode = "{0}".equals( userDnTemplate.trim() ) ? EscapeMode.NONE : EscapeMode.DN; + userDn = expandTemplate( userDnTemplate, matchedPrincipal, escapeMode ); LOG.computedUserDn( userDn, principal ); return userDn; } // Create the searchBase and searchFilter from config. - String searchBase = expandTemplate( getUserSearchBase(), matchedPrincipal ); + String searchBase = expandTemplate( getUserSearchBase(), matchedPrincipal, EscapeMode.DN ); String searchFilter; if ( userSearchFilter == null ) { if ( userSearchAttributeName == null ) { @@ -700,10 +717,10 @@ protected String getUserDn( final String principal ) throws IllegalArgumentExcep "(&(objectclass=%1$s)(%2$s=%3$s))", getUserObjectClass(), userSearchAttributeName, - expandTemplate( getUserSearchAttributeTemplate(), matchedPrincipal ) ); + expandTemplate(getUserSearchAttributeTemplate(), matchedPrincipal, EscapeMode.FILTER)); } } else { - searchFilter = expandTemplate( userSearchFilter, matchedPrincipal ); + searchFilter = expandTemplate(userSearchFilter, matchedPrincipal, EscapeMode.FILTER); } SearchControls searchControls = getUserSearchControls(); @@ -744,22 +761,75 @@ protected String getUserDn( final String principal ) throws IllegalArgumentExcep @Override protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token, Object ldapPrincipal, Object ldapCredentials, LdapContext ldapContext) throws NamingException { - HashRequest.Builder builder = new HashRequest.Builder(); - Hash credentialsHash = hashService.computeHash(builder.setSource(token.getCredentials()).setAlgorithmName(HASHING_ALGORITHM).build()); - return new SimpleAuthenticationInfo(token.getPrincipal(), credentialsHash.toHex(), credentialsHash.getSalt(), getName()); + final ByteSource credentialsSalt = new SecureRandomNumberGenerator().nextBytes(); + final SimpleHash credentialsHash = new SimpleHash(HASHING_ALGORITHM, token.getCredentials(), credentialsSalt, HASHING_ITERATIONS); + return new SimpleAuthenticationInfo(token.getPrincipal(), credentialsHash.toHex(), credentialsSalt, getName()); } - private static String expandTemplate( final String template, final Matcher input ) { + private static String expandTemplate( final String template, final Matcher input, final EscapeMode escapeMode ) { String output = template; Matcher matcher = TEMPLATE_PATTERN.matcher( output ); while( matcher.find() ) { String lookupStr = matcher.group( 1 ); int lookupIndex = Integer.parseInt( lookupStr ); String lookupValue = input.group( lookupIndex ); - output = matcher.replaceFirst( lookupValue == null ? "" : lookupValue ); + if (lookupValue == null) { + lookupValue = ""; + } else if (escapeMode == EscapeMode.FILTER) { + lookupValue = escapeLdapSearchFilterValue(lookupValue); + } else if (escapeMode == EscapeMode.DN) { + lookupValue = escapeDnValue(lookupValue); + } + // A substituted value that itself contains a template token (e.g. a username of + // "{0}") would be re-matched on the next scan and expand forever. No legitimate + // principal contains a "{}" token, so reject it rather than loop. + if (TEMPLATE_PATTERN.matcher(lookupValue).find()) { + throw new IllegalArgumentException("Illegal template placeholder in substituted value"); + } + // quoteReplacement is required: replaceFirst treats '\' and '$' in the replacement specially + output = matcher.replaceFirst(Matcher.quoteReplacement(lookupValue)); matcher = TEMPLATE_PATTERN.matcher( output ); } return output; } + private static String escapeLdapSearchFilterValue(final String value) { + if (value == null) { + return null; + } + final StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + switch (c) { + case '\\': + sb.append("\\5c"); + break; + case '*': + sb.append("\\2a"); + break; + case '(': + sb.append("\\28"); + break; + case ')': + sb.append("\\29"); + break; + case '\0': + sb.append("\\00"); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } + + // RFC 4514 DN-value escaping (escapes ',', '=', '+', '"', '\\', '<', '>', ';', + // leading/trailing space, and a leading '#'), preventing DN injection. + static String escapeDnValue(final String value) { + if (value == null) { + return null; + } + return Rdn.escapeValue(value); + } + } diff --git a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxPamRealm.java b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxPamRealm.java index a0c17ecb9d..623f90d5f1 100644 --- a/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxPamRealm.java +++ b/gateway-provider-security-shiro/src/main/java/org/apache/knox/gateway/shirorealm/KnoxPamRealm.java @@ -39,10 +39,9 @@ import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; -import org.apache.shiro.crypto.hash.DefaultHashService; -import org.apache.shiro.crypto.hash.Hash; -import org.apache.shiro.crypto.hash.HashRequest; -import org.apache.shiro.crypto.hash.HashService; +import org.apache.shiro.crypto.SecureRandomNumberGenerator; +import org.apache.shiro.crypto.hash.SimpleHash; +import org.apache.shiro.lang.util.ByteSource; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; @@ -82,6 +81,14 @@ */ public class KnoxPamRealm extends AuthorizingRealm { private static final String HASHING_ALGORITHM = "SHA-256"; + /* + * KNOX-3421: Shiro 2.x's DefaultHashService applies a large per-algorithm default + * iteration count (e.g. 50000 for SHA-256), whereas HashedCredentialsMatcher still + * defaults to a single iteration. To keep the credential round-trip consistent under + * the SHA-256 scheme, the stored hash is computed here with an explicit salt and this + * pinned iteration count, which matches the matcher configured in the constructor. + */ + private static final int HASHING_ITERATIONS = 1; private static final String SUBJECT_USER_ROLES = "subject.userRoles"; private static final String SUBJECT_USER_GROUPS = "subject.userGroups"; @@ -89,7 +96,6 @@ public class KnoxPamRealm extends AuthorizingRealm { private static final Auditor auditor = auditService.getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME, AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME); - private final HashService hashService = new DefaultHashService(); private final KnoxShiroMessages shiroLog = MessagesFactory.get(KnoxShiroMessages.class); private final GatewayMessages gatewayLog = MessagesFactory.get(GatewayMessages.class); @@ -97,6 +103,7 @@ public class KnoxPamRealm extends AuthorizingRealm { public KnoxPamRealm() { HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher(HASHING_ALGORITHM); + credentialsMatcher.setHashIterations(HASHING_ITERATIONS); setCredentialsMatcher(credentialsMatcher); } @@ -147,18 +154,13 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) } } - HashRequest hashRequest = new HashRequest.Builder() - .setSource(token.getCredentials()) - .setAlgorithmName(HASHING_ALGORITHM) - .build(); - Hash credentialsHash = hashService.computeHash(hashRequest); + return createAuthenticationInfo(token, new UnixUserPrincipal(user)); + } - /* Coverity Scan CID 1361684 */ - if (credentialsHash == null) { - handleAuthFailure(token, "Failed to compute hash", null); - } - return new SimpleAuthenticationInfo(new UnixUserPrincipal(user), credentialsHash.toHex(), - credentialsHash.getSalt(), getName()); + protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token, Object principal) { + final ByteSource credentialsSalt = new SecureRandomNumberGenerator().nextBytes(); + final SimpleHash credentialsHash = new SimpleHash(HASHING_ALGORITHM, token.getCredentials(), credentialsSalt, HASHING_ITERATIONS); + return new SimpleAuthenticationInfo(principal, credentialsHash.toHex(), credentialsSalt, getName()); } private void handleAuthFailure(AuthenticationToken token, String errorMessage, Exception e) { diff --git a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmDnEscapingTest.java b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmDnEscapingTest.java new file mode 100644 index 0000000000..e8270367af --- /dev/null +++ b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmDnEscapingTest.java @@ -0,0 +1,70 @@ +/* + * 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.shirorealm; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class KnoxLdapRealmDnEscapingTest { + + // ---- escapeDnValue: the helper used at BOTH DN-construction sites ---- + + @Test + public void plainValueIsUnchanged() { + assertEquals("guest", KnoxLdapRealm.escapeDnValue("guest")); + } + + @Test + public void dnMetacharactersAreEscaped() { + // ',' and '=' must be escaped so the value cannot add or alter RDNs + assertEquals("guest\\,ou\\=admin", KnoxLdapRealm.escapeDnValue("guest,ou=admin")); + } + + @Test + public void plusAndQuoteAreEscaped() { + assertEquals("a\\+b\\\"c", KnoxLdapRealm.escapeDnValue("a+b\"c")); + } + + @Test + public void nullIsNullSafe() { + assertNull(KnoxLdapRealm.escapeDnValue(null)); + } + + // ---- getUserDn: proves the userDnTemplate ({0}) expansion escapes as a DN ---- + + @Test + public void userDnTemplatePlainPrincipalUnchanged() { + KnoxLdapRealm realm = new KnoxLdapRealm(); + realm.setUserDnTemplate("uid={0},ou=people,dc=hadoop,dc=apache,dc=org"); + assertEquals("uid=guest,ou=people,dc=hadoop,dc=apache,dc=org", + realm.getUserDn("guest")); + } + + @Test + public void userDnTemplateInjectionIsEscaped() { + KnoxLdapRealm realm = new KnoxLdapRealm(); + realm.setUserDnTemplate("uid={0},ou=people,dc=hadoop,dc=apache,dc=org"); + // Without escaping this would inject an extra RDN and rewrite the bind DN. + assertEquals("uid=guest\\,ou\\=admin,ou=people,dc=hadoop,dc=apache,dc=org", + realm.getUserDn("guest,ou=admin")); + } +} diff --git a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmHashRoundTripTest.java b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmHashRoundTripTest.java new file mode 100644 index 0000000000..33f5c198dd --- /dev/null +++ b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmHashRoundTripTest.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.shirorealm; + +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.authc.credential.CredentialsMatcher; +import org.junit.Test; + +import javax.naming.ldap.LdapContext; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class KnoxLdapRealmHashRoundTripTest { + + /** Exposes the protected createAuthenticationInfo for testing. */ + private static class TestableRealm extends KnoxLdapRealm { + AuthenticationInfo build(AuthenticationToken token) throws Exception { + return createAuthenticationInfo(token, token.getPrincipal(), token.getCredentials(), (LdapContext) null); + } + } + + @Test + public void correctPasswordMatchesUnderShiro221() throws Exception { + TestableRealm realm = new TestableRealm(); + UsernamePasswordToken stored = new UsernamePasswordToken("alice", "s3cr3t"); + AuthenticationInfo info = realm.build(stored); + + CredentialsMatcher matcher = realm.getCredentialsMatcher(); + UsernamePasswordToken submittedGood = new UsernamePasswordToken("alice", "s3cr3t"); + assertTrue("correct password must match", matcher.doCredentialsMatch(submittedGood, info)); + } + + @Test + public void wrongPasswordIsRejectedUnderShiro221() throws Exception { + TestableRealm realm = new TestableRealm(); + UsernamePasswordToken stored = new UsernamePasswordToken("alice", "s3cr3t"); + AuthenticationInfo info = realm.build(stored); + + CredentialsMatcher matcher = realm.getCredentialsMatcher(); + UsernamePasswordToken submittedBad = new UsernamePasswordToken("alice", "wrong"); + assertFalse("wrong password must not match", matcher.doCredentialsMatch(submittedBad, info)); + } +} diff --git a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmTest.java b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmTest.java index d26bdbbe00..18e45bed03 100644 --- a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmTest.java +++ b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxLdapRealmTest.java @@ -19,13 +19,136 @@ package org.apache.knox.gateway.shirorealm; +import org.apache.shiro.realm.ldap.LdapContextFactory; +import org.easymock.Capture; +import org.easymock.EasyMock; import org.junit.Test; +import javax.naming.NamingEnumeration; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.LdapContext; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; public class KnoxLdapRealmTest { + private static String captureSearchFilter(KnoxLdapRealm realm, String principal) throws Exception { + LdapContextFactory factory = EasyMock.createNiceMock(LdapContextFactory.class); + LdapContext ctx = EasyMock.createNiceMock(LdapContext.class); + NamingEnumeration results = EasyMock.createNiceMock(NamingEnumeration.class); + + EasyMock.expect(factory.getSystemLdapContext()).andReturn(ctx).anyTimes(); + Capture filter = EasyMock.newCapture(); + EasyMock.expect(ctx.search(EasyMock.anyString(), EasyMock.capture(filter), + EasyMock.anyObject(SearchControls.class))).andReturn(results); + EasyMock.expect(results.hasMore()).andReturn(false).anyTimes(); + EasyMock.replay(factory, ctx, results); + + realm.setContextFactory(factory); + try { + realm.getUserDn(principal); + } catch (IllegalArgumentException expected) { + // mock returns no entry, so getUserDn throws after the search; we only need the filter + } + return filter.getValue(); + } + + private static String captureSearchBase(KnoxLdapRealm realm, String principal) throws Exception { + LdapContextFactory factory = EasyMock.createNiceMock(LdapContextFactory.class); + LdapContext ctx = EasyMock.createNiceMock(LdapContext.class); + NamingEnumeration results = EasyMock.createNiceMock(NamingEnumeration.class); + + EasyMock.expect(factory.getSystemLdapContext()).andReturn(ctx).anyTimes(); + Capture base = EasyMock.newCapture(); + EasyMock.expect(ctx.search(EasyMock.capture(base), EasyMock.anyString(), + EasyMock.anyObject(SearchControls.class))).andReturn(results); + EasyMock.expect(results.hasMore()).andReturn(false).anyTimes(); + EasyMock.replay(factory, ctx, results); + + realm.setContextFactory(factory); + try { + realm.getUserDn(principal); + } catch (IllegalArgumentException expected) { + // mock returns no entry, so getUserDn throws after the search; we only need the base + } + return base.getValue(); + } + + private static KnoxLdapRealm searchModeRealm() { + KnoxLdapRealm realm = new KnoxLdapRealm(); + realm.setSearchBase("dc=hadoop,dc=apache,dc=org"); + realm.setUserSearchBase("ou=people,dc=hadoop,dc=apache,dc=org"); + realm.setUserSearchAttributeName("uid"); + realm.setUserObjectClass("person"); + return realm; + } + + @Test + public void getUserDnEscapesLdapFilterMetacharacters() throws Exception { + String filter = captureSearchFilter(searchModeRealm(), "*)(uid=admin"); + assertEquals("(&(objectclass=person)(uid=\\2a\\29\\28uid=admin))", filter); + } + + @Test + public void getUserDnEscapesWildcard() throws Exception { + String filter = captureSearchFilter(searchModeRealm(), "*"); + assertEquals("(&(objectclass=person)(uid=\\2a))", filter); + } + + @Test + public void getUserDnEscapesBackslash() throws Exception { + String filter = captureSearchFilter(searchModeRealm(), "a\\b"); + assertEquals("(&(objectclass=person)(uid=a\\5cb))", filter); + } + + @Test + public void getUserDnLeavesLegitimateUsernameUnchanged() throws Exception { + String filter = captureSearchFilter(searchModeRealm(), "sam"); + assertEquals("(&(objectclass=person)(uid=sam))", filter); + } + + @Test + public void getUserDnEscapesValueButPreservesOperatorFilterStructure() throws Exception { + KnoxLdapRealm realm = searchModeRealm(); + realm.setUserSearchFilter("(uid={0})"); + String filter = captureSearchFilter(realm, "a)(b"); + assertEquals("(uid=a\\29\\28b)", filter); + } + + @Test + public void getUserDnEscapesSearchBaseTemplateValue() throws Exception { + KnoxLdapRealm realm = searchModeRealm(); + // A userSearchBase that substitutes the raw principal into the base DN. + realm.setUserSearchBase("ou={0},dc=hadoop,dc=apache,dc=org"); + // Injection metacharacters in the username must be DN-escaped so they + // cannot add or rewrite RDNs in the search base. + String base = captureSearchBase(realm, "people,dc=evil"); + assertEquals("ou=people\\,dc\\=evil,dc=hadoop,dc=apache,dc=org", base); + } + + @Test + public void getUserDnWithDefaultTemplateReturnsFullDnUnescaped() { + // The default userDnTemplate is "{0}", meaning the principal IS the complete bind DN + // (the system-bind case, e.g. KnoxCLI system-user-auth-test). DN-escaping would turn + // the ','/'=' separators into '\,'/'\=' and corrupt the DN, so this template must pass + // the principal through unescaped. Embedded templates ("uid={0},...") still escape. + KnoxLdapRealm realm = new KnoxLdapRealm(); + String dn = realm.getUserDn("uid=guest,ou=people,dc=hadoop,dc=apache,dc=org"); + assertEquals("uid=guest,ou=people,dc=hadoop,dc=apache,dc=org", dn); + } + + @Test(timeout = 5000, expected = IllegalArgumentException.class) + public void getUserDnRejectsTemplatePlaceholderAsUsername() { + // A username of "{0}" substituted into a template would re-introduce a "{0}" token + // and loop forever. It must be rejected (auth failure), not expanded. The timeout + // guards against regression of the infinite loop. + KnoxLdapRealm realm = new KnoxLdapRealm(); + realm.setUserDnTemplate("uid={0},ou=people,dc=hadoop,dc=apache,dc=org"); + realm.getUserDn("{0}"); + } + @Test public void setGetSearchBase() { KnoxLdapRealm realm = new KnoxLdapRealm(); diff --git a/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxPamRealmHashRoundTripTest.java b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxPamRealmHashRoundTripTest.java new file mode 100644 index 0000000000..73e32277c4 --- /dev/null +++ b/gateway-provider-security-shiro/src/test/java/org/apache/knox/gateway/shirorealm/KnoxPamRealmHashRoundTripTest.java @@ -0,0 +1,61 @@ +/* + * 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.shirorealm; + +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.authc.credential.CredentialsMatcher; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class KnoxPamRealmHashRoundTripTest { + + /** Exposes the protected createAuthenticationInfo for testing. */ + private static class TestableRealm extends KnoxPamRealm { + AuthenticationInfo build(AuthenticationToken token) { + return createAuthenticationInfo(token, token.getPrincipal()); + } + } + + @Test + public void correctPasswordMatchesUnderShiro221() { + TestableRealm realm = new TestableRealm(); + UsernamePasswordToken stored = new UsernamePasswordToken("alice", "s3cr3t"); + AuthenticationInfo info = realm.build(stored); + + CredentialsMatcher matcher = realm.getCredentialsMatcher(); + UsernamePasswordToken submittedGood = new UsernamePasswordToken("alice", "s3cr3t"); + assertTrue("correct password must match", matcher.doCredentialsMatch(submittedGood, info)); + } + + @Test + public void wrongPasswordIsRejectedUnderShiro221() { + TestableRealm realm = new TestableRealm(); + UsernamePasswordToken stored = new UsernamePasswordToken("alice", "s3cr3t"); + AuthenticationInfo info = realm.build(stored); + + CredentialsMatcher matcher = realm.getCredentialsMatcher(); + UsernamePasswordToken submittedBad = new UsernamePasswordToken("alice", "wrong"); + assertFalse("wrong password must not match", matcher.doCredentialsMatch(submittedBad, info)); + } +} 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/pom.xml b/gateway-server/pom.xml index 3ddfb07ab8..5fc869f205 100644 --- a/gateway-server/pom.xml +++ b/gateway-server/pom.xml @@ -208,6 +208,14 @@ org.apache.shiro shiro-web + + org.apache.shiro + shiro-config-core + + + org.apache.shiro + shiro-lang + commons-beanutils @@ -417,6 +425,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/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java index d4639b0114..a71fbf5b3b 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java @@ -738,6 +738,12 @@ void errorRespondingToConfigChange(String source, @Message(level = MessageLevel.ERROR, text = "Error while initializing {0}: {1}") void errorInitializingService(String implementation, String error, @StackTrace(level = MessageLevel.DEBUG) Exception e); + @Message(level = MessageLevel.DEBUG, text = "Failed to list topology directory {0} while detecting KnoxIDF: {1}") + void failedToListTopologyDirForKnoxIdfDetection(String topologyDir, String error, @StackTrace(level = MessageLevel.DEBUG) Exception e); + + @Message(level = MessageLevel.DEBUG, text = "Failed to read topology file {0} while detecting KnoxIDF: {1}") + void failedToReadTopologyFileForKnoxIdfDetection(String topologyFile, String error, @StackTrace(level = MessageLevel.DEBUG) Exception e); + @Message(level = MessageLevel.ERROR, text = "Unable to complete service discovery for cluster {0} topology = {1}.") void failedToDiscoverClusterServices(String clusterName, String topologyName, diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/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/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java index 061518537d..56ea2d8169 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 @@ -392,6 +392,13 @@ public class GatewayConfigImpl extends Configuration implements GatewayConfig { public static final String STRICT_TRANSPORT_ENABLED = GATEWAY_CONFIG_FILE_PREFIX + ".strict.transport.enabled"; public static final String STRICT_TRANSPORT_OPTION = GATEWAY_CONFIG_FILE_PREFIX + ".strict.transport.option"; + // Gateway LDAP Properties + public static final int DEFAULT_LDAP_PORT = 3890; + public static final String DEFAULT_LDAP_BASE_DN = "dc=proxy,dc=com"; + public static final int DEFAULT_LDAP_MAX_SIZE_LIMIT = 1000; + /* The default max time for LDAP search in milliseconds */ + public static final int DEFAULT_LDAP_MAX_TIME_LIMIT = 60 * 1000; + public GatewayConfigImpl() { init(); } @@ -1010,6 +1017,25 @@ public String getSigningKeyPassphraseAlias() { } } + @Override + public List getSigningKeyAliases() { + final List aliases = new ArrayList<>(); + final String current = getSigningKeyAlias(); + if (current != null) { + aliases.add(current); + } + final String additional = get(SIGNING_KEY_ALIASES_ADDITIONAL); + if (additional != null && !additional.trim().isEmpty() && !"none".equalsIgnoreCase(additional.trim())) { + for (String alias : additional.trim().split("\\s*,\\s*")) { + // Skip blanks and de-duplicate so the current key is never published/checked twice. + if (!alias.isEmpty() && !aliases.contains(alias)) { + aliases.add(alias); + } + } + } + return aliases; + } + @Override public List getGlobalRulesServices() { String value = get( GLOBAL_RULES_SERVICES ); @@ -1775,17 +1801,17 @@ public String getStrictTransportOption() { // LDAP Service Configuration @Override public boolean isLDAPEnabled() { - return Boolean.parseBoolean(get(LDAP_ENABLED, "false")); + return getBoolean(LDAP_ENABLED, false); } @Override public int getLDAPPort() { - return Integer.parseInt(get(LDAP_PORT, "3890")); + return getInt(LDAP_PORT, DEFAULT_LDAP_PORT); } @Override public String getLDAPBaseDN() { - return get(LDAP_BASE_DN, "dc=proxy,dc=com"); + return get(LDAP_BASE_DN, DEFAULT_LDAP_BASE_DN); } @Override @@ -1840,7 +1866,7 @@ public Map getLDAPInterceptorConfig(String interceptorName) { @Override public boolean isLDAPRecursiveGroupResolutionEnabled() { - return Boolean.parseBoolean(get(LDAP_RECURSIVE_GROUP_RESOLUTION, "false")); + return getBoolean(LDAP_RECURSIVE_GROUP_RESOLUTION, false); } @Override @@ -1865,7 +1891,7 @@ public String getLdapRolesLookupFilePath() { @Override public boolean isLDAPSSLEnabled() { - return Boolean.parseBoolean(get(LDAP_SSL_ENABLED, "false")); + return getBoolean(LDAP_SSL_ENABLED, false); } @Override @@ -1884,8 +1910,48 @@ public List getLDAPSSLEnabledCipherSuites() { return cipherSuites == null ? Collections.emptyList() : cipherSuites; } + @Override + public int getLDAPMaxSizeLimit() { + return getInt(LDAP_MAX_SIZE_LIMIT, DEFAULT_LDAP_MAX_SIZE_LIMIT); + } + + @Override + public int getLDAPMaxTimeLimit() { + return getInt(LDAP_MAX_TIME_LIMIT, DEFAULT_LDAP_MAX_TIME_LIMIT); + } + @Override 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); + } + + @Override + public int getKnoxIDFFederatedOpConnectTimeoutMs() { + return getInt(KNOXIDF_FEDERATED_OP_CONNECT_TIMEOUT_MS, KNOXIDF_FEDERATED_OP_CONNECT_TIMEOUT_MS_DEFAULT); + } + + @Override + public int getKnoxIDFFederatedOpReadTimeoutMs() { + return getInt(KNOXIDF_FEDERATED_OP_READ_TIMEOUT_MS, KNOXIDF_FEDERATED_OP_READ_TIMEOUT_MS_DEFAULT); + } } 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..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 @@ -42,6 +42,18 @@ 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 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/java/org/apache/knox/gateway/database/DatabaseType.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/DatabaseType.java index 2009872782..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 @@ -22,37 +22,55 @@ 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, + AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL ), 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, + AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL ), 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, + AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL ), 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, + AbstractDataSourceFactory.KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL ), 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, + AbstractDataSourceFactory.DERBY_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL ), 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, + AbstractDataSourceFactory.ORACLE_KNOXIDF_TRUSTED_OIDC_ISSUERS_TABLE_SQL ); private final String type; @@ -60,13 +78,21 @@ public enum DatabaseType { private final String metadataTableSql; private final String providersTableSql; 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) { + 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; this.providersTableSql = providersTableSql; this.descriptorsTableSql = descriptorsTableSql; + this.federatedIdentityTableSql = federatedIdentityTableSql; + this.federatedIdentityAttrTableSql = federatedIdentityAttrTableSql; + this.trustedOidcIssuersTableSql = trustedOidcIssuersTableSql; } public String type() { @@ -89,6 +115,18 @@ public String descriptorsTableSql() { return descriptorsTableSql; } + public String federatedIdentityTableSql() { + return federatedIdentityTableSql; + } + + 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/database/JDBCUtils.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java index 014f8144f6..30e2ab59fe 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/JDBCUtils.java @@ -36,7 +36,7 @@ public static boolean tableExists(String tableName, DataSource dataSource) throw boolean exists; try (Connection connection = dataSource.getConnection()) { final DatabaseMetaData dbMetadata = connection.getMetaData(); - final String tableNameToCheck = dbMetadata.storesUpperCaseIdentifiers() ? tableName : tableName.toLowerCase(Locale.ROOT); + final String tableNameToCheck = normalizeIdentifier(tableName, dbMetadata); try (ResultSet tables = dbMetadata.getTables(connection.getCatalog(), null, tableNameToCheck, null)) { exists = tables.next(); } @@ -44,6 +44,24 @@ public static boolean tableExists(String tableName, DataSource dataSource) throw return exists; } + /** + * Normalises an unquoted identifier to the case the driver actually stores it in, so it can be + * matched against {@link DatabaseMetaData#getTables}. Derby (and other uppercase-storing + * engines) store an unquoted {@code federated_identity} as {@code FEDERATED_IDENTITY}; passing + * the name verbatim would match nothing and cause {@code createTableIfNotExists} to re-run the + * CREATE and fail with "table already exists". Callers that use already-uppercase constants + * (KNOX_TOKENS, KNOX_PROVIDERS, TRUSTED_OIDC_ISSUERS) are unaffected since upper-casing them is + * a no-op. + */ + private static String normalizeIdentifier(String identifier, DatabaseMetaData dbMetadata) throws SQLException { + if (dbMetadata.storesUpperCaseIdentifiers()) { + return identifier.toUpperCase(Locale.ROOT); + } else if (dbMetadata.storesLowerCaseIdentifiers()) { + return identifier.toLowerCase(Locale.ROOT); + } + return identifier; + } + public static void createTableFromSQL(String createSqlFileName, DataSource dataSource, ClassLoader classLoader) throws Exception { try (InputStream is = classLoader.getResourceAsStream(createSqlFileName); Connection connection = dataSource.getConnection();Statement createTableStatement = connection.createStatement()) { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java new file mode 100644 index 0000000000..dd6ee68e88 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java @@ -0,0 +1,37 @@ +/* + * 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 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)) { + // Resolve the DDL resource via the actual subclass's classloader so each KnoxDatabase + // subclass (TokenStateDatabase, FederatedIdentityDatabase) loads its own create*.sql + // rather than being coupled to one hardcoded sibling class's classloader. + JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, getClass().getClassLoader()); + } + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/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..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 @@ -86,6 +86,10 @@ 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)); + + 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/AbstractServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java index 4b60fa434e..2049c72bb4 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/AbstractServiceFactory.java @@ -17,9 +17,16 @@ */ package org.apache.knox.gateway.services.factory; +import java.io.IOException; import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collection; +import java.util.Locale; import java.util.Map; +import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.apache.knox.gateway.GatewayMessages; @@ -33,6 +40,8 @@ import org.apache.knox.gateway.services.security.AliasService; import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.gateway.services.topology.TopologyService; +import org.apache.knox.gateway.topology.Topology; public abstract class AbstractServiceFactory implements ServiceFactory { @@ -40,6 +49,10 @@ public abstract class AbstractServiceFactory implements ServiceFactory { private static final String IMPLEMENTATION_PARAM_NAME = "impl"; private static final String EMPTY_DEFAULT_IMPLEMENTATION = ""; + /** Topology service roles that enable the KnoxIDF-backed service implementations. */ + private static final String KNOXIDF_ROLE = "KNOXIDF"; + private static final String KNOXIDF_ADMIN_ROLE = "KNOXIDF_ADMIN"; + @Override public Service create(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map options) throws ServiceLifecycleException { return create(gatewayServices, serviceType, gatewayConfig, options, getImplementation(gatewayConfig)); @@ -105,6 +118,72 @@ protected void logServiceUsage(String implementation, ServiceType serviceType) { LOG.usingServiceImplementation(isEmptyDefaultImplementation(implementation) ? "default" : implementation, serviceType.getServiceTypeName()); } + /** + * Returns {@code true} if any topology enables KnoxIDF (a service with role {@code KNOXIDF} or + * {@code KNOXIDF_ADMIN}). + *

+ * Service factories run during {@code DefaultGatewayServices.init}, before the topology monitor + * has loaded any topologies, so {@link TopologyService#getTopologies()} is typically empty at + * this point. To detect KnoxIDF anyway we fall back to scanning the on-disk topology directory + * for the enabling role. The in-memory check is kept first so a caller that runs after topologies + * are loaded still works. Known limitation: descriptor-generated {@code .topology} files that are + * not yet materialised on disk at init time are not seen (still strictly better than relying on + * the empty in-memory map alone). + */ + protected boolean isKnoxIdfEnabledInAnyTopology(GatewayServices gatewayServices, GatewayConfig gatewayConfig) { + final TopologyService topologyService = gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE); + if (topologyService != null) { + for (Topology topology : topologyService.getTopologies()) { + if (topology.getServices().stream().anyMatch(service -> isKnoxIdfRole(service.getRole()))) { + return true; + } + } + } + return isKnoxIdfEnabledOnDisk(gatewayConfig); + } + + private static boolean isKnoxIdfRole(String role) { + return KNOXIDF_ROLE.equals(role) || KNOXIDF_ADMIN_ROLE.equals(role); + } + + private static boolean isKnoxIdfEnabledOnDisk(GatewayConfig gatewayConfig) { + if (gatewayConfig == null) { + return false; + } + final String topologyDir = gatewayConfig.getGatewayTopologyDir(); + if (StringUtils.isBlank(topologyDir)) { + return false; + } + final Path dir = Paths.get(topologyDir); + if (!Files.isDirectory(dir)) { + return false; + } + try (Stream files = Files.list(dir)) { + return files.filter(AbstractServiceFactory::isTopologyFile).anyMatch(AbstractServiceFactory::topologyFileEnablesKnoxIdf); + } catch (IOException e) { + LOG.failedToListTopologyDirForKnoxIdfDetection(topologyDir, e.getMessage(), e); + return false; + } + } + + private static boolean isTopologyFile(Path path) { + if (!Files.isRegularFile(path)) { + return false; + } + final String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".xml") || name.endsWith(".topology"); + } + + private static boolean topologyFileEnablesKnoxIdf(Path path) { + try { + final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + return content.contains("" + KNOXIDF_ROLE + "") || content.contains("" + KNOXIDF_ADMIN_ROLE + ""); + } catch (IOException e) { + LOG.failedToReadTopologyFileForKnoxIdfDetection(path.toString(), e.getMessage(), e); + return false; + } + } + // abstract methods protected abstract Service createService(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map options, diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java new file mode 100644 index 0000000000..fbfeaa6d06 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactory.java @@ -0,0 +1,122 @@ +/* + * 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.database.DatabaseType; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.Service; +import org.apache.knox.gateway.services.ServiceLifecycleException; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.knoxidf.federation.DerbyDBFederatedIdentityService; +import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentityService; +import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService; +import org.apache.knox.gateway.services.knoxidf.federation.JdbcFederatedIdentityService; + +import 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 = EmptyFederatedIdentityService.class.getName(); + + @Override + protected Service createService(GatewayServices gatewayServices, ServiceType serviceType, GatewayConfig gatewayConfig, Map options, String implementation) + throws ServiceLifecycleException { + + String implementationToUse = implementation; + // No explicit impl configured: auto-select a persistence backend when KnoxIDF is deployed. + // Otherwise honor the configured impl (very likely a prod JDBC store). + if (isEmptyDefaultImplementation(implementationToUse) && isKnoxIdfEnabledInAnyTopology(gatewayServices, gatewayConfig)) { + implementationToUse = chooseAutoImplementation(gatewayConfig); + } + + FederatedIdentityService service = null; + if (shouldCreateService(implementationToUse)) { + if (matchesImplementation(implementationToUse, EmptyFederatedIdentityService.class, true)) { + service = new EmptyFederatedIdentityService(); + } else if (matchesImplementation(implementationToUse, DerbyDBFederatedIdentityService.class)) { + service = createDerbyService(gatewayServices, gatewayConfig, options); + } else if (matchesImplementation(implementationToUse, JdbcFederatedIdentityService.class)) { + service = createJdbcService(gatewayServices, gatewayConfig, options); + } + logServiceUsage(service.getClass().getName(), serviceType); + } + return service; + } + + /** + * Chooses the auto-enabled implementation when KnoxIDF is deployed with no explicit impl: an + * operator-configured external database wins (very likely a prod JDBC store), otherwise a + * self-provisioning embedded Derby store (the {@code none}/{@code derbydb} default) so + * federation works out of the box without any extra infrastructure. + */ + String chooseAutoImplementation(GatewayConfig gatewayConfig) { + return isExternalDatabaseConfigured(gatewayConfig) + ? JdbcFederatedIdentityService.class.getName() + : DerbyDBFederatedIdentityService.class.getName(); + } + + private boolean isExternalDatabaseConfigured(GatewayConfig gatewayConfig) { + final String databaseType = gatewayConfig.getDatabaseType(); + try { + return DatabaseType.fromString(databaseType) != DatabaseType.DERBY; + } catch (IllegalArgumentException e) { + // "none" (the default) or any unrecognized value: no real external DB -> use Derby. + return false; + } + } + + private FederatedIdentityService createDerbyService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map options) { + try { + final DerbyDBFederatedIdentityService derbyService = new DerbyDBFederatedIdentityService(); + derbyService.setAliasService(getAliasService(gatewayServices)); + derbyService.setMasterService(getMasterService(gatewayServices)); + derbyService.init(gatewayConfig, options); + return derbyService; + } catch (ServiceLifecycleException e) { + LOG.errorInitializingService(DerbyDBFederatedIdentityService.class.getName(), e.getMessage(), e); + return new EmptyFederatedIdentityService(); + } + } + + private FederatedIdentityService createJdbcService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map options) { + try { + final JdbcFederatedIdentityService jdbcService = new JdbcFederatedIdentityService(); + jdbcService.setAliasService(getAliasService(gatewayServices)); + jdbcService.init(gatewayConfig, options); + return jdbcService; + } catch (ServiceLifecycleException e) { + LOG.errorInitializingService(JdbcFederatedIdentityService.class.getName(), e.getMessage(), e); + return new EmptyFederatedIdentityService(); + } + } + + @Override + protected ServiceType getServiceType() { + return ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE; + } + + @Override + protected Collection getKnownImplementations() { + return List.of(DEFAULT_IMPLEMENTATION, JdbcFederatedIdentityService.class.getName(), DerbyDBFederatedIdentityService.class.getName()); + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java new file mode 100644 index 0000000000..e8219c8e36 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactory.java @@ -0,0 +1,127 @@ +/* + * 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.database.DatabaseType; +import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.Service; +import org.apache.knox.gateway.services.ServiceLifecycleException; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.DerbyDBTrustedOidcIssuerService; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService; + +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; + // No explicit impl configured: auto-select a persistence backend when KnoxIDF is deployed. + // Otherwise honor the configured impl (very likely a prod JDBC store). + if (isEmptyDefaultImplementation(implementationToUse) && isKnoxIdfEnabledInAnyTopology(gatewayServices, gatewayConfig)) { + implementationToUse = chooseAutoImplementation(gatewayConfig); + } + + TrustedOidcIssuerService service = null; + if (shouldCreateService(implementationToUse)) { + if (matchesImplementation(implementationToUse, EmptyTrustedOidcIssuerService.class, true)) { + service = new EmptyTrustedOidcIssuerService(); + } else if (matchesImplementation(implementationToUse, DerbyDBTrustedOidcIssuerService.class)) { + service = createDerbyService(gatewayServices, gatewayConfig, options); + } else if (matchesImplementation(implementationToUse, JdbcTrustedOidcIssuerService.class)) { + service = createJdbcService(gatewayServices, gatewayConfig, options); + } + if (service != null) { + logServiceUsage(service.getClass().getName(), serviceType); + } + } + return service; + } + + /** + * Chooses the auto-enabled implementation when KnoxIDF is deployed with no explicit impl: an + * operator-configured external database wins (very likely a prod JDBC store), otherwise a + * self-provisioning embedded Derby store (the {@code none}/{@code derbydb} default) so the + * trusted OIDC issuer registry works out of the box without any extra infrastructure. + */ + String chooseAutoImplementation(GatewayConfig gatewayConfig) { + return isExternalDatabaseConfigured(gatewayConfig) + ? JdbcTrustedOidcIssuerService.class.getName() + : DerbyDBTrustedOidcIssuerService.class.getName(); + } + + private boolean isExternalDatabaseConfigured(GatewayConfig gatewayConfig) { + final String databaseType = gatewayConfig.getDatabaseType(); + try { + return DatabaseType.fromString(databaseType) != DatabaseType.DERBY; + } catch (IllegalArgumentException e) { + // "none" (the default) or any unrecognized value: no real external DB -> use Derby. + return false; + } + } + + private TrustedOidcIssuerService createDerbyService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map options) + throws ServiceLifecycleException { + try { + final DerbyDBTrustedOidcIssuerService derbyService = new DerbyDBTrustedOidcIssuerService(); + derbyService.setAliasService(getAliasService(gatewayServices)); + derbyService.setMasterService(getMasterService(gatewayServices)); + derbyService.init(gatewayConfig, options); + return derbyService; + } catch (ServiceLifecycleException e) { + LOG.errorInitializingService(DerbyDBTrustedOidcIssuerService.class.getName(), e.getMessage(), e); + return new EmptyTrustedOidcIssuerService(); + } + } + + private TrustedOidcIssuerService createJdbcService(GatewayServices gatewayServices, GatewayConfig gatewayConfig, Map options) + throws ServiceLifecycleException { + try { + final JdbcTrustedOidcIssuerService jdbcService = new JdbcTrustedOidcIssuerService(); + jdbcService.setAliasService(getAliasService(gatewayServices)); + jdbcService.init(gatewayConfig, options); + return jdbcService; + } catch (ServiceLifecycleException e) { + LOG.errorInitializingService(JdbcTrustedOidcIssuerService.class.getName(), e.getMessage(), e); + return new EmptyTrustedOidcIssuerService(); + } + } + + @Override + protected ServiceType getServiceType() { + return ServiceType.TRUSTED_OIDC_ISSUER_SERVICE; + } + + @Override + protected Collection getKnownImplementations() { + return List.of(DEFAULT_IMPLEMENTATION, JdbcTrustedOidcIssuerService.class.getName(), DerbyDBTrustedOidcIssuerService.class.getName()); + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityService.java new file mode 100644 index 0000000000..aa4bec22c0 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityService.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_NAME; +import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_TYPE; +import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME; +import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME; +import static org.apache.knox.gateway.database.DatabaseType.DERBY; +import static org.apache.knox.gateway.services.security.AliasService.NO_CLUSTER_NAME; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.hadoop.conf.Configuration; +import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.services.ServiceLifecycleException; +import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.gateway.services.token.impl.DerbyDBTokenStateService; +import org.apache.knox.gateway.shell.jdbc.derby.DerbyDatabase; + +/** + * A self-provisioning, embedded-Derby backed {@link FederatedIdentityService}. This is the + * auto-enabled default when KnoxIDF is deployed without an operator-configured external database, + * mirroring how {@link DerbyDBTokenStateService} is the default token-state service. + *

+ * It reuses the single embedded Derby database that the token-state service already provisions + * under {@code ${securityDir}/tokens} (the {@code ;create=true} JDBC URL is idempotent, so + * connecting to an already-booted database simply connects), sets the shared {@link GatewayConfig} + * to point at it, ensures the connection user/password aliases exist, and then delegates all + * persistence to {@link JdbcFederatedIdentityService} (which builds the + * {@link FederatedIdentityDatabase} and self-creates its tables). + */ +public class DerbyDBFederatedIdentityService extends JdbcFederatedIdentityService { + + private DerbyDatabase derbyDatabase; + private Path derbyDatabaseFolder; + private MasterService masterService; + + public void setMasterService(MasterService masterService) { + this.masterService = masterService; + } + + @Override + public void init(GatewayConfig config, Map options) throws ServiceLifecycleException { + try { + derbyDatabaseFolder = Paths.get(config.getGatewaySecurityDir(), DerbyDBTokenStateService.DB_NAME); + startDerby(); + ((Configuration) config).set(GATEWAY_DATABASE_TYPE, DERBY.type()); + ((Configuration) config).set(GATEWAY_DATABASE_NAME, derbyDatabaseFolder.toString()); + getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_USER_ALIAS_NAME, getDatabaseUserName()); + getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_PASSWORD_ALIAS_NAME, getDatabasePassword()); + super.init(config, options); + } catch (Exception e) { + throw new ServiceLifecycleException("Error while initiating DerbyDBFederatedIdentityService: " + e, e); + } + } + + private void startDerby() throws Exception { + derbyDatabase = new DerbyDatabase(derbyDatabaseFolder.toString()); + derbyDatabase.create(); + TimeUnit.SECONDS.sleep(1); // give a bit of time for the server to start + } + + private String getDatabasePassword() throws Exception { + final char[] dbPasswordAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_PASSWORD_ALIAS_NAME); + return dbPasswordAliasValue != null ? new String(dbPasswordAliasValue) : new String(masterService.getMasterSecret()); + } + + private String getDatabaseUserName() throws Exception { + final char[] dbUserAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_USER_ALIAS_NAME); + return dbUserAliasValue != null ? new String(dbUserAliasValue) : DerbyDBTokenStateService.DEFAULT_TOKEN_DB_USER_NAME; + } + + @Override + public void stop() throws ServiceLifecycleException { + try { + if (derbyDatabase != null) { + derbyDatabase.shutdown(); + } + } catch (Exception e) { + throw new ServiceLifecycleException("Error while shutting down Derby Database", e); + } + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentityService.java new file mode 100644 index 0000000000..6738b2660c --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/EmptyFederatedIdentityService.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 EmptyFederatedIdentityService implements FederatedIdentityService { + @Override + public FederatedIdentity addFederatedIdentity(FederatedIdentity identity) { + return 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..0fc2790a41 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java @@ -0,0 +1,148 @@ +/* + * 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 id, user_id, provider, external_subject, external_issuer, created_at FROM " + + FEDERATED_IDENTITY_TABLE_NAME + " WHERE provider = ? AND external_issuer = ? AND external_subject = ?"; + private static final String FETCH_FEDERATED_IDENTITY_SQL_BY_ID = "SELECT id, user_id, provider, external_subject, external_issuer, created_at FROM " + + FEDERATED_IDENTITY_TABLE_NAME + " WHERE id = ?"; + private static final String FETCH_FEDERATED_IDENTITY_ATTR_SQL = "SELECT attr_key, attr_value FROM " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME + " WHERE identity_id = ?"; + + 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 { + // Persist the core identity row and its attribute rows atomically on a single connection + // with autocommit off: either the identity and all its attributes commit together, or the + // whole write rolls back. Previously each INSERT ran on its own auto-committed connection, + // so a failure between them could leave an identity persisted without its attributes. + try (Connection connection = dataSource.getConnection()) { + final boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + // save core metadata first + try (PreparedStatement addFederatedIdentityStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_SQL)) { + addFederatedIdentityStatement.setString(1, identity.getId()); + addFederatedIdentityStatement.setString(2, identity.getUserId()); + addFederatedIdentityStatement.setString(3, identity.getProvider()); + addFederatedIdentityStatement.setString(4, identity.getExternalSubject()); + addFederatedIdentityStatement.setString(5, identity.getExternalIssuer()); + addFederatedIdentityStatement.setTimestamp(6, Timestamp.from(identity.getCreatedAt())); + addFederatedIdentityStatement.executeUpdate(); + } + + // save attributes + try (PreparedStatement addFederatedIdentityAttrStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_ATTR_SQL)) { + for (var attribute : identity.getAttributes().entrySet()) { + addFederatedIdentityAttrStatement.setString(1, identity.getId()); + addFederatedIdentityAttrStatement.setString(2, attribute.getKey()); + addFederatedIdentityAttrStatement.setString(3, attribute.getValue()); + addFederatedIdentityAttrStatement.addBatch(); + } + addFederatedIdentityAttrStatement.executeBatch(); + } + connection.commit(); + } catch (SQLException e) { + connection.rollback(); + throw e; + } finally { + connection.setAutoCommit(previousAutoCommit); + } + } + } + + + 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..1241e54e72 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java @@ -0,0 +1,38 @@ +/* + * 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); + + @Message(level = MessageLevel.DEBUG, text = "Federated identity ({0} / {1} / {2}) already exists; skipping insert") + void federatedIdentityAlreadyExists(String provider, String issuer, String subject); +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java new file mode 100644 index 0000000000..aec849d67c --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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.sql.SQLIntegrityConstraintViolationException; +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 { + // Double-checked locking: re-test under the lock so a thread that blocked while + // another was initialising does not re-initialise the database a second time. + if (!initialized.get()) { + if (aliasService == null) { + throw new ServiceLifecycleException("The required AliasService reference has not been set."); + } + try { + this.federatedIdentityDatabase = new FederatedIdentityDatabase(DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType()); + initialized.set(true); + } catch (Exception e) { + throw new ServiceLifecycleException("Error while initiating JdbcFederatedIdentityService: " + e, e); + } + } + } finally { + initLock.unlock(); + } + } + } + + @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 FederatedIdentity addFederatedIdentity(FederatedIdentity identity) { + // Insert-and-catch rather than check-then-insert: the UNIQUE(provider, external_issuer, + // external_subject) index is the atomic arbiter, so two concurrent requests for the same + // external identity cannot both insert. A unique-constraint violation means the row already + // exists, which is exactly the desired end state, so it is treated as benign rather than + // surfaced as an error (closing the prior TOCTOU race between the pre-check and the insert). + try { + federatedIdentityDatabase.addFederatedIdentity(identity); + return identity; + } catch (SQLException e) { + if (isUniqueConstraintViolation(e)) { + LOG.federatedIdentityAlreadyExists(identity.getProvider(), identity.getExternalIssuer(), identity.getExternalSubject()); + // The concurrent winner owns the canonical primary key; our in-memory identity carries a + // different random id (UUID.randomUUID) that was never persisted. Return the stored row so + // callers never mint a token/auth-code against a phantom id. Fall back to the local copy + // only if the re-query itself fails (findByProviderAndSubject swallows read errors). + return findByProviderAndSubject(identity.getProvider(), identity.getExternalIssuer(), + identity.getExternalSubject()).orElse(identity); + } + LOG.errorSavingFederatedIdentityInDatabase(identity.getId(), e.getMessage(), e); + throw new FederatedIdentityServiceException("An error occurred while saving Federated Identity " + identity.getId() + " in the database", e); + } + } + + /** + * Recognises a unique/primary-key constraint violation across dialects: either a + * {@link SQLIntegrityConstraintViolationException} or any {@link SQLException} in the cause + * chain whose SQLState is in the {@code 23} (integrity constraint violation) class. + */ + private static boolean isUniqueConstraintViolation(SQLException e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof SQLIntegrityConstraintViolationException) { + return true; + } + if (t instanceof SQLException) { + final String sqlState = ((SQLException) t).getSQLState(); + if (sqlState != null && sqlState.startsWith("23")) { + return true; + } + } + } + return false; + } + + @Override + public Optional 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/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java new file mode 100644 index 0000000000..f1c73e9b38 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerService.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_NAME; +import static org.apache.knox.gateway.config.impl.GatewayConfigImpl.GATEWAY_DATABASE_TYPE; +import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_PASSWORD_ALIAS_NAME; +import static org.apache.knox.gateway.database.AbstractDataSourceFactory.DATABASE_USER_ALIAS_NAME; +import static org.apache.knox.gateway.database.DatabaseType.DERBY; +import static org.apache.knox.gateway.services.security.AliasService.NO_CLUSTER_NAME; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.hadoop.conf.Configuration; +import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.services.ServiceLifecycleException; +import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.gateway.services.token.impl.DerbyDBTokenStateService; +import org.apache.knox.gateway.shell.jdbc.derby.DerbyDatabase; + +/** + * A self-provisioning, embedded-Derby backed {@link TrustedOidcIssuerService}. This is the + * auto-enabled default when KnoxIDF is deployed without an operator-configured external database, + * mirroring how {@link DerbyDBTokenStateService} is the default token-state service and + * {@code DerbyDBFederatedIdentityService} is the default federated-identity service. + *

+ * It reuses the single embedded Derby database that the token-state service already provisions + * under {@code ${securityDir}/tokens} (the {@code ;create=true} JDBC URL is idempotent, so + * connecting to an already-booted database simply connects), sets the shared {@link GatewayConfig} + * to point at it, ensures the connection user/password aliases exist, and then delegates all + * persistence to {@link JdbcTrustedOidcIssuerService} (which builds the + * {@link TrustedOidcIssuerDatabase} and self-creates its table). + */ +public class DerbyDBTrustedOidcIssuerService extends JdbcTrustedOidcIssuerService { + + private DerbyDatabase derbyDatabase; + private Path derbyDatabaseFolder; + private MasterService masterService; + + public void setMasterService(MasterService masterService) { + this.masterService = masterService; + } + + @Override + public void init(GatewayConfig config, Map options) throws ServiceLifecycleException { + try { + derbyDatabaseFolder = Paths.get(config.getGatewaySecurityDir(), DerbyDBTokenStateService.DB_NAME); + startDerby(); + ((Configuration) config).set(GATEWAY_DATABASE_TYPE, DERBY.type()); + ((Configuration) config).set(GATEWAY_DATABASE_NAME, derbyDatabaseFolder.toString()); + getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_USER_ALIAS_NAME, getDatabaseUserName()); + getAliasService().addAliasForCluster(NO_CLUSTER_NAME, DATABASE_PASSWORD_ALIAS_NAME, getDatabasePassword()); + super.init(config, options); + } catch (Exception e) { + throw new ServiceLifecycleException("Error while initiating DerbyDBTrustedOidcIssuerService: " + e, e); + } + } + + private void startDerby() throws Exception { + derbyDatabase = new DerbyDatabase(derbyDatabaseFolder.toString()); + derbyDatabase.create(); + TimeUnit.SECONDS.sleep(1); // give a bit of time for the server to start + } + + private String getDatabasePassword() throws Exception { + final char[] dbPasswordAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_PASSWORD_ALIAS_NAME); + return dbPasswordAliasValue != null ? new String(dbPasswordAliasValue) : new String(masterService.getMasterSecret()); + } + + private String getDatabaseUserName() throws Exception { + final char[] dbUserAliasValue = getAliasService().getPasswordFromAliasForGateway(DATABASE_USER_ALIAS_NAME); + return dbUserAliasValue != null ? new String(dbUserAliasValue) : DerbyDBTokenStateService.DEFAULT_TOKEN_DB_USER_NAME; + } + + @Override + public void stop() throws ServiceLifecycleException { + try { + if (derbyDatabase != null) { + derbyDatabase.shutdown(); + } + } catch (Exception e) { + throw new ServiceLifecycleException("Error while shutting down Derby Database", e); + } + } +} diff --git a/gateway-server/src/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..3f345fe571 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java @@ -0,0 +1,187 @@ +/* + * 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.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.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); + + + 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 { + // Double-checked locking: re-test under the lock so a thread that blocked while another was + // initialising does not re-initialise the database/discoveryHelper a second time (mirrors + // JdbcFederatedIdentityService). Without this inner check a startup race overwrote the + // already-built database and discoveryHelper references. + if (!initialized.get()) { + if (aliasService == null) { + throw new ServiceLifecycleException("The required AliasService reference has not been set."); + } + try { + this.maxTrustedIssuers = config.getTrustedOidcIssuerMaxTrustedIssuers(); + this.database = new TrustedOidcIssuerDatabase( + DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType()); + this.discoveryHelper = new OIDCDiscoveryHelper(this, config.getTrustedOidcIssuerDiscoveryCacheTtlSecs(), + OIDCDiscoveryHelper.buildHttpClient(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs(), config.getTrustedOidcIssuerDiscoveryReadTimeoutMs())); + reloadRegistrySnapshot(); + initialized.set(true); + } catch (ServiceLifecycleException e) { + throw e; + } catch (Exception e) { + throw new ServiceLifecycleException("Error initializing JdbcTrustedOidcIssuerService: " + e, e); + } + } + } finally { + initLock.unlock(); + } + } + } + + @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); + 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/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..05b9d4f723 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuerDatabase.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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; + + 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; + } +} 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/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java index 3e42c2117c..e4445276e1 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java @@ -72,7 +72,8 @@ public class KnoxLDAPServerManager { @VisibleForTesting DirectoryService directoryService; - private LdapServer ldapServer; + @VisibleForTesting + LdapServer ldapServer; private GatewayConfig gatewayConfig; private List interceptors; private boolean hasRolesLookupInterceptor; @@ -87,6 +88,8 @@ public class KnoxLDAPServerManager { private List sslEnabledCipherSuites; // Collection of DNs for the proxied backend LDAP servers private Set baseDns; + private int maxSizeLimit; + private int maxTimeLimit; KnoxLDAPServerManager(AliasService aliasService) { this(aliasService, null); @@ -114,6 +117,9 @@ public void initialize(GatewayConfig config) throws Exception { this.baseDn = config.getLDAPBaseDN(); this.bindUser = config.getLDAPBindUser(); + maxSizeLimit = config.getLDAPMaxSizeLimit(); + maxTimeLimit = config.getLDAPMaxTimeLimit(); + // Secure (LDAPS) transport configuration. When enabled but no dedicated keystore is // configured, fall back to the gateway identity keystore so the embedded server can // reuse the gateway's own TLS material out of the box. @@ -148,6 +154,13 @@ private void createInterceptors(GatewayConfig config) throws Exception { // Add common configuration interceptorConfig.put("baseDn", baseDn); + if (!interceptorConfig.containsKey("maxResultSetSize")) { + // Set the backend to return more results than the proxy's size limit. + // This will ensure that the proxy will return "Size limit exceeded" + if (maxSizeLimit != 0) { + interceptorConfig.put("maxResultSetSize", Integer.toString(maxSizeLimit + 1)); + } + } // Add common LDAP Proxy configurations to backends if ("backend".equalsIgnoreCase(interceptorConfig.get("interceptorType"))) { @@ -241,6 +254,9 @@ public void start() throws Exception { ldapServer.setTransports(transport); ldapServer.setDirectoryService(directoryService); + ldapServer.setMaxSizeLimit(maxSizeLimit); + ldapServer.setMaxTimeLimit(maxTimeLimit); + ldapServer.start(); LOG.ldapServiceStarted(port); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java index 816fef0035..8683f46457 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java @@ -73,6 +73,10 @@ public interface LdapMessages { text = "Creating LDAP interceptor: {0} (via {1})") void ldapInterceptorCreating(String interceptorName, String source); + @Message(level = MessageLevel.INFO, + text = "Configuring LDAP interceptor {0}: {1} = {2}") + void ldapInterceptorConfiguring(String interceptorName, String configName, String configValue); + @Message(level = MessageLevel.INFO, text = "Loading backend: {0} (via {1})") void ldapBackendLoading(String backendName, String source); @@ -101,6 +105,18 @@ public interface LdapMessages { text = "LDAP Search: {0} | {1}") void ldapSearch(String baseDn, String filter); + @Message(level = MessageLevel.DEBUG, + text = "LDAP Paged Search: {0} | {1}, page size {2}, page {3}") + void ldapPagedSearch(String baseDn, String filter, int pageSize, int pageNumber); + + @Message(level = MessageLevel.ERROR, + text = "LDAP Paged Search Exceeded Max Result Set Size: {0} | {1}") + void ldapPagedSearchExceededMaxResultSetSize(int resultSetSize, int maxResultSetSize); + + @Message(level = MessageLevel.DEBUG, + text = "LDAP Paged Search Completed: {0} | {1}") + void ldapPagedSearchCompleted(String baseDn, String filter); + @Message(level = MessageLevel.ERROR, text = "LDAP Search failed: {0} | {1}, {2}") void ldapSearchFailed(String baseDn, String filter, @StackTrace(level = MessageLevel.DEBUG) Exception e); @@ -133,9 +149,9 @@ public interface LdapMessages { text = "Backend user not found: {0}") void ldapUserNull(String username); - @Message(level = MessageLevel.ERROR, + @Message(level = MessageLevel.DEBUG, text = "Failed to copy attribute: {0}") - void ldapAttributeCopyError(@StackTrace(level = MessageLevel.DEBUG) Exception e); + void ldapAttributeCopyError(@StackTrace(level = MessageLevel.TRACE) Exception e); @Message(level = MessageLevel.DEBUG, text = "LDAP authentication succeeded for user: {0}") void ldapAuthSucceeded(String user); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java index 0c56168520..32fd033b24 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java @@ -21,12 +21,20 @@ import org.apache.directory.api.ldap.model.cursor.CursorException; import org.apache.directory.api.ldap.model.cursor.EntryCursor; +import org.apache.directory.api.ldap.model.cursor.SearchCursor; import org.apache.directory.api.ldap.model.entry.Attribute; import org.apache.directory.api.ldap.model.entry.DefaultEntry; import org.apache.directory.api.ldap.model.entry.Entry; import org.apache.directory.api.ldap.model.entry.Value; import org.apache.directory.api.ldap.model.exception.LdapException; +import org.apache.directory.api.ldap.model.message.Response; +import org.apache.directory.api.ldap.model.message.SearchRequest; +import org.apache.directory.api.ldap.model.message.SearchRequestImpl; +import org.apache.directory.api.ldap.model.message.SearchResultDone; +import org.apache.directory.api.ldap.model.message.SearchResultEntry; import org.apache.directory.api.ldap.model.message.SearchScope; +import org.apache.directory.api.ldap.model.message.controls.PagedResults; +import org.apache.directory.api.ldap.model.message.controls.PagedResultsImpl; import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; import org.apache.directory.ldap.client.api.DefaultLdapConnectionFactory; @@ -50,7 +58,6 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -96,6 +103,8 @@ public class LdapProxyBackend implements LdapBackend { private boolean useMemberOf; // Use memberOf attribute for group lookup (efficient for AD) private boolean recursiveGroupResolution; private int recursiveGroupResolutionMaxDepth; + private int pageSize; + private int maxResultSetSize; private final String proxyEntryGroupMembershipAttributeType = "memberOf"; @@ -184,6 +193,11 @@ public LdapProxyBackend(String name, Map config) { recursiveGroupResolution = Boolean.parseBoolean(config.getOrDefault("recursiveGroupResolution", "false")); recursiveGroupResolutionMaxDepth = Integer.parseInt(config.getOrDefault("recursiveGroupResolutionMaxDepth", "3")); + // Configure search parameters + pageSize = Integer.parseInt(config.getOrDefault("pageSize", "1000")); + maxResultSetSize = Integer.parseInt(config.getOrDefault("maxResultSetSize", "0")); // 0 means unlimited + LOG.ldapInterceptorConfiguring(name, "maxResultSetSize", Integer.toString(maxResultSetSize)); + // Configure secure transport (LDAPS) to the remote server. An ldaps:// URL enables it // by default; an explicit useSsl setting always wins. final boolean ldapsFromUrl = ldapUrl != null && ldapUrl.toLowerCase(Locale.ROOT).startsWith("ldaps://"); @@ -490,15 +504,14 @@ public List getUserGroups(String username, SchemaManager schemaManager) return List.of(); } - LdapConnection connection = null; - try { - connection = getConnection(); - List groups = getUserGroupsEntries(connection, user, createEntryCache(), createResolvedParentsCache()); - List cns = getCnsFromEntries(groups); - return cns; - } finally { - releaseConnection(connection); + List groups = new ArrayList<>(); + Attribute groupsAttribute = user.get(proxyEntryGroupMembershipAttributeType); + if (groupsAttribute != null) { + for (Value value : groupsAttribute) { + groups.add(new Dn(value.getString()).getRdn().getValue()); + } } + return groups; } @Override @@ -511,12 +524,10 @@ public List searchUsers(String filter, SchemaManager schemaManager) throw try { connection = getConnection(); String ldapFilter = "(" + remoteUserIdentifierAttribute + "=" + filter.trim() + ")"; - try (EntryCursor cursor = connection.search(remoteUserSearchBase, ldapFilter, SearchScope.SUBTREE, "*")) { - while (cursor.next()) { - Entry sourceEntry = cursor.get(); - addGroupMemberships(sourceEntry, connection, entryCache, resolvedParentsCache); - results.add(remoteSchemaConverter.convertRemoteEntryToProxyEntry(sourceEntry, schemaManager)); - } + List searchResults = performPagedSearch(connection, remoteUserSearchBase, ldapFilter, SearchScope.SUBTREE, "*"); + for (Entry sourceEntry : searchResults) { + addGroupMemberships(sourceEntry, connection, entryCache, resolvedParentsCache); + results.add(remoteSchemaConverter.convertRemoteEntryToProxyEntry(sourceEntry, schemaManager)); } return results; } finally { @@ -535,14 +546,10 @@ public List search(String searchBase, SearchScope searchScope, String fil try { connection = getConnection(); List results = new ArrayList<>(); - try (EntryCursor cursor = connection.search(remoteSearchBase, remoteFilter, searchScope, "*")) { - while (cursor.next()) { - Entry entry = cursor.get(); - addGroupMemberships(entry, connection, entryCache, resolvedParentsCache); - results.add(remoteSchemaConverter.convertRemoteEntryToProxyEntry(entry, schemaManager)); - } - } catch (LdapException e) { - LOG.ldapSearchFailed(remoteSearchBase, remoteFilter, e); + List searchResults = performPagedSearch(connection, remoteSearchBase, remoteFilter, searchScope, "*"); + for (Entry entry : searchResults) { + addGroupMemberships(entry, connection, entryCache, resolvedParentsCache); + results.add(remoteSchemaConverter.convertRemoteEntryToProxyEntry(entry, schemaManager)); } return results; } finally { @@ -721,21 +728,19 @@ private List resolveGroupsRecursive(LdapConnection connection, List searchResults = performPagedSearch(connection, remoteGroupSearchBase, filter, SearchScope.SUBTREE, "cn", "memberUid", "member", "uniqueMember"); + for (Entry parentGroup : searchResults) { + String parentDn = parentGroup.getDn().getNormName(); + + // Update cache for all groups found in this search + updateCache(entryCache, resolvedParentsCache, groupsToSearch, parentGroup); + + if (!allGroupDns.contains(parentDn)) { + allGroupDns.add(parentDn); + allGroups.add(parentGroup); + nextLevelGroups.add(parentGroup); + } else { + LOG.ldapRecursiveGroupSearchCycleDetected(entryName, parentDn); } } @@ -839,13 +844,69 @@ private List getUserGroupsInternal(LdapConnection connection, Dn... dns) String filter = buildMultipleGroupMemberFilter(dns); - try (EntryCursor cursor = connection.search(remoteGroupSearchBase, filter, SearchScope.SUBTREE, "cn")) { - while (cursor.next()) { - groups.add(cursor.get()); + groups.addAll(performPagedSearch(connection, remoteGroupSearchBase, filter, SearchScope.SUBTREE, "cn")); + + return groups; + } + + protected List performPagedSearch(LdapConnection connection, String baseDn, String filter, SearchScope scope, String... attributes ) throws LdapException, CursorException, IOException { + List results = new ArrayList<>(); + + // 1. Setup basic search parameters + SearchRequest searchRequest = new SearchRequestImpl(); + searchRequest.setBase(new Dn(baseDn)); + searchRequest.setFilter(filter); + searchRequest.setScope(scope); + searchRequest.addAttributes(attributes); + + // 2. Initialize the PagedResults control + PagedResults pagedControl = new PagedResultsImpl(); + pagedControl.setSize(pageSize); + searchRequest.addControl(pagedControl); + + byte[] cookie = null; + + // 3. Loop until no more pages remain + int pageNumber = 1; + do { + // Update cookie for the subsequent pages + if (cookie != null) { + pagedControl.setCookie(cookie); } + + try (SearchCursor cursor = connection.search(searchRequest)) { + LOG.ldapPagedSearch(baseDn, filter, pageSize, pageNumber); + while (cursor.next()) { + Response response = cursor.get(); + + // Process matching entries + if (response instanceof SearchResultEntry) { + Entry entry = ((SearchResultEntry) response).getEntry(); + results.add(entry); + } + } + if (cursor.isDone()) { + SearchResultDone done = cursor.getSearchResultDone(); + PagedResults responseControl = (PagedResults) done.getControl(PagedResults.OID); + + if (responseControl != null) { + cookie = responseControl.getCookie(); + } else { + cookie = null; + } + } + pageNumber++; + } + } while (cookie != null && cookie.length > 0 && + (maxResultSetSize == 0 || results.size() < maxResultSetSize)); + + if (maxResultSetSize != 0 && results.size() >= maxResultSetSize) { + LOG.ldapPagedSearchExceededMaxResultSetSize(results.size(), maxResultSetSize); + } else { + LOG.ldapPagedSearchCompleted(baseDn, filter); } - return groups; + return results; } private String buildMultipleGroupMemberFilter(Dn... dns) { @@ -877,21 +938,6 @@ private String buildMultipleGroupMemberFilter(Dn... dns) { return filterBuilder.toString(); } - private List getCnsFromEntries(Collection entries) throws LdapException { - List cns = new ArrayList<>(); - for (Entry entry : entries) { - Attribute cnAttr = entry.get("cn"); - if (cnAttr != null) { - cns.add(cnAttr.getString()); - } else if (entry.getDn() != null && entry.getDn().getRdn() != null) { - // Fall back to the CN carried in the DN when the entry was fetched without the - // cn attribute, so resolved groups are not silently dropped from the result. - cns.add(entry.getDn().getRdn().getValue()); - } - } - return cns; - } - protected Map createEntryCache() { return new HashMap<>(); } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java index 034b27ce54..98b720a3da 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java @@ -32,14 +32,17 @@ import java.security.interfaces.RSAPublicKey; import java.text.ParseException; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.JWSVerifier; import com.nimbusds.jose.KeyLengthException; @@ -202,17 +205,62 @@ private boolean verifyTokenUsingRSA(JWT token, RSAPublicKey publicKey) throws To try { PublicKey key = publicKey; if (key == null) { - key = keystoreService.getSigningKeystore().getCertificate(getSigningKeyAlias()).getPublicKey(); + key = selectVerificationKey(token); } final JWSVerifier verifier = new RSASSAVerifier((RSAPublicKey) key); - // TODO: interrogate the token for issuer claim in order to determine the public key to use for verification - // consider jwk for specifying the key too return token.verify(verifier); } catch (KeyStoreException | KeystoreServiceException e) { throw new TokenServiceException("Cannot verify token.", e); } } + /** + * Selects the public key to verify a gateway-signed RSA token with. When more than one signing + * key alias is configured, the token's {@code kid} header is matched against each configured + * key's SHA-256 thumbprint so a token signed by a rotated-out key still verifies. When there is a + * single configured key, or the token carries no matching {@code kid}, this falls back to the + * current signing key — the historical single-key behavior. + */ + private PublicKey selectVerificationKey(JWT token) throws KeyStoreException, KeystoreServiceException { + final KeyStore keystore = keystoreService.getSigningKeystore(); + final List aliases = getVerificationKeyAliases(); + // Fast path / backward compatibility: a single configured key means no kid selection is needed. + if (aliases.size() > 1) { + final String kid = extractKid(token); + if (kid != null) { + for (final String alias : aliases) { + final Certificate cert = keystore.getCertificate(alias); + if (cert == null || !(cert.getPublicKey() instanceof RSAPublicKey)) { + continue; + } + try { + if (kid.equals(TokenUtils.getThumbprint((RSAPublicKey) cert.getPublicKey(), "SHA-256"))) { + return cert.getPublicKey(); + } + } catch (JOSEException e) { + // Cannot compute this key's thumbprint; skip it and try the next alias. + LOG.errorGettingKid(e.toString()); + } + } + } + } + // No kid, no match, or a single key: verify with the current signing key. + return keystore.getCertificate(getSigningKeyAlias()).getPublicKey(); + } + + private List getVerificationKeyAliases() { + final List aliases = config == null ? null : config.getSigningKeyAliases(); + return (aliases == null || aliases.isEmpty()) ? Collections.singletonList(getSigningKeyAlias()) : aliases; + } + + private static String extractKid(JWT token) { + try { + return JWSHeader.parse(token.getHeader()).getKeyID(); + } catch (ParseException e) { + return null; + } + } + private boolean verifyTokenUsingHMAC(JWT token) throws TokenServiceException { try { final JWSVerifier verifier = new MACVerifier(getHmacSecret()); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java index afca435bd0..95d0b8eabd 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java @@ -265,6 +265,22 @@ public void revokeToken(final String tokenId) throws UnknownTokenException { log.revokedToken(Tokens.getTokenIDDisplayText(tokenId)); } + @Override + public boolean consumeToken(final String tokenId) { + validateTokenIdentifier(tokenId); + // Atomic single-use claim: ConcurrentHashMap#remove returns the prior value to exactly one + // caller, so concurrent redemptions of the same token can never both observe it as present. + final boolean claimed = tokenExpirations.remove(tokenId) != null; + if (claimed) { + // Evict the remaining per-token state (idempotent for a token we won the race for). + tokenIssueTimes.remove(tokenId); + maxTokenLifetimes.remove(tokenId); + metadataMap.remove(tokenId); + log.revokedToken(Tokens.getTokenIDDisplayText(tokenId)); + } + return claimed; + } + @Override public boolean isExpired(final JWTToken token) throws UnknownTokenException { return getTokenExpiration(token) <= System.currentTimeMillis(); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java index 4dfc46d298..5d60fbe33f 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java @@ -240,6 +240,25 @@ protected void removeToken(String tokenId) throws UnknownTokenException { } } + @Override + public boolean consumeToken(String tokenId) { + // The single-row primary-key DELETE is the atomic arbiter: only the caller whose statement + // actually removed the row observes rowsAffected == 1, so exactly one concurrent redemption + // wins. Fail closed on a SQL error (report "not consumed by us") rather than the inherited + // removeToken() behaviour of swallowing the exception, which would falsely signal a win. + try { + final boolean removed = tokenDatabase.removeToken(tokenId); + if (removed) { + super.removeTokens(Collections.singleton(tokenId)); // evict the in-memory cache copy + log.removedTokenFromDatabase(Tokens.getTokenIDDisplayText(tokenId)); + } + return removed; + } catch (SQLException e) { + log.errorRemovingTokenFromDatabase(Tokens.getTokenIDDisplayText(tokenId), e.getMessage(), e); + return false; + } + } + @Override protected void evictExpiredTokens() { try { diff --git a/gateway-server/src/main/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/java/org/apache/knox/gateway/services/topology/impl/DefaultTopologyService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/topology/impl/DefaultTopologyService.java index 02d916f29c..0468e4aee8 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/topology/impl/DefaultTopologyService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/topology/impl/DefaultTopologyService.java @@ -306,13 +306,17 @@ public void setAliasService(AliasService as) { public void deployTopology(Topology t){ try { + File topology = new File(topologiesDirectory.getAbsolutePath() + "/" + t.getName() + ".xml"); + if (!topology.getCanonicalFile().toPath().startsWith(topologiesDirectory.getCanonicalFile().toPath())) { + throw new IOException("Resolved topology path escapes managed directory: " + t.getName()); + } + File temp = new File(topologiesDirectory.getAbsolutePath() + "/" + t.getName() + ".xml.temp"); Marshaller mr = jaxbContext.createMarshaller(); mr.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); mr.marshal(t, temp); - File topology = new File(topologiesDirectory.getAbsolutePath() + "/" + t.getName() + ".xml"); if(!temp.renameTo(topology)) { FileUtils.forceDelete(temp); throw new IOException("Could not rename temp file"); @@ -716,8 +720,15 @@ private static boolean writeConfig(File dest, String name, String content) { File destFile = new File(dest, name); try { - FileUtils.writeStringToFile(destFile, content, StandardCharsets.UTF_8); - log.wroteConfigurationFile(destFile.getAbsolutePath()); + final File canonicalDest = dest.getCanonicalFile(); + final File canonicalFile = destFile.getCanonicalFile(); + if (!canonicalFile.toPath().startsWith(canonicalDest.toPath())) { + log.failedToWriteConfigurationFile(destFile.getAbsolutePath(), + new IOException("Resolved path escapes managed directory: " + name)); + return false; + } + FileUtils.writeStringToFile(canonicalFile, content, StandardCharsets.UTF_8); + log.wroteConfigurationFile(canonicalFile.getAbsolutePath()); result = true; } catch (IOException e) { log.failedToWriteConfigurationFile(destFile.getAbsolutePath(), e); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/topology/monitor/ZkRemoteConfigurationMonitorService.java b/gateway-server/src/main/java/org/apache/knox/gateway/topology/monitor/ZkRemoteConfigurationMonitorService.java index 0d8e34c57c..2c6d89a389 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/topology/monitor/ZkRemoteConfigurationMonitorService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/topology/monitor/ZkRemoteConfigurationMonitorService.java @@ -212,14 +212,14 @@ public void stop() throws ServiceLifecycleException { @Override public boolean createProvider(String name, String content) { - String entryPath = "/knox/config/shared-providers/" + name; + String entryPath = "/knox/config/shared-providers/" + FilenameUtils.getName(name); client.createEntry(entryPath, content); return (client.getEntryData(entryPath) != null); } @Override public boolean createDescriptor(String name, String content) { - String entryPath = "/knox/config/descriptors/" + name; + String entryPath = "/knox/config/descriptors/" + FilenameUtils.getName(name); client.createEntry(entryPath, content); return (client.getEntryData(entryPath) != null); } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java index ea77ad9bf5..6f0b162863 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java @@ -91,13 +91,14 @@ import org.apache.knox.gateway.topology.Topology; import org.apache.knox.gateway.topology.validation.TopologyValidator; import org.apache.shiro.SecurityUtils; +import org.apache.shiro.UnavailableSecurityManagerException; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.config.ConfigurationException; import org.apache.shiro.config.Ini; -import org.apache.shiro.config.IniSecurityManagerFactory; +import org.apache.shiro.env.BasicIniEnvironment; +import org.apache.shiro.lang.util.LifecycleUtils; import org.apache.shiro.subject.Subject; -import org.apache.shiro.util.Factory; import org.apache.shiro.util.ThreadContext; import org.eclipse.persistence.oxm.MediaType; import org.jboss.shrinkwrap.api.exporter.ExplodedExporter; @@ -1769,6 +1770,8 @@ protected boolean authenticateUser(Ini ini, UsernamePasswordToken token){ } catch ( Exception e ) { out.println(e.getCause()); out.println(e.toString()); + } finally { + destroySecurityManager(); } return result; } @@ -1866,9 +1869,7 @@ private char[] getSystemPassword(Topology t) throws NoSuchProviderException, Mis protected Subject getSubject(Ini config) throws BadSubjectException { try { ThreadContext.unbindSubject(); - @SuppressWarnings("deprecation") - Factory factory = new IniSecurityManagerFactory(config); - org.apache.shiro.mgt.SecurityManager securityManager = (org.apache.shiro.mgt.SecurityManager) factory.getInstance(); + org.apache.shiro.mgt.SecurityManager securityManager = new BasicIniEnvironment(config).getSecurityManager(); SecurityUtils.setSecurityManager(securityManager); Subject subject = SecurityUtils.getSubject(); if( subject != null) { @@ -1882,6 +1883,25 @@ protected Subject getSubject(Ini config) throws BadSubjectException { throw new BadSubjectException("Subject could not be created with Shiro Config at " + config); } + /** + * Releases the Shiro {@link org.apache.shiro.mgt.SecurityManager} created for the + * current command by {@link #getSubject(Ini)}. The {@code DefaultSecurityManager} + * built by {@link BasicIniEnvironment} is {@code Destroyable} and can hold + * resources such as a cache manager and a session-validation scheduler thread, so + * it must be destroyed once the Subject is no longer needed to avoid leaking them. + * Null-safe and safe to call when no SecurityManager is currently set. + */ + protected void destroySecurityManager() { + final org.apache.shiro.mgt.SecurityManager securityManager; + try { + securityManager = SecurityUtils.getSecurityManager(); + } catch (UnavailableSecurityManagerException e) { + return; // nothing was set, nothing to release + } + LifecycleUtils.destroy(securityManager); + SecurityUtils.setSecurityManager(null); + } + protected Subject getSubject(String config) throws ConfigurationException { Ini ini = new Ini(); ini.loadFromPath(config); @@ -2118,6 +2138,8 @@ private Set getGroups(Topology t, UsernamePasswordToken token){ if(debug){ e.printStackTrace(); } + } finally { + destroySecurityManager(); } return groups; } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java index f275ee9eeb..60b41c5cdd 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandler.java @@ -25,6 +25,8 @@ import org.apache.knox.gateway.services.registry.ServiceDefEntry; import org.apache.knox.gateway.services.registry.ServiceDefinitionRegistry; import org.apache.knox.gateway.services.registry.ServiceRegistry; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.AliasServiceException; import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.services.security.KeystoreServiceException; import org.apache.knox.gateway.webshell.WebshellWebSocketAdapter; @@ -68,6 +70,12 @@ public class GatewayWebsocketHandler extends WebSocketHandler static final String REGEX_SPLIT_SERVICE_PATH = "^((?:[^/]*/){3}[^/]*)"; + static final String TRUSTSTORE_USER_PROPERTY = "org.apache.knox.gateway.websockets.truststore"; + + static final String KEYSTORE_USER_PROPERTY = "org.apache.knox.gateway.websockets.keystore"; + + static final String KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY = "org.apache.knox.gateway.websockets.keystore.key.passphrase"; + static final String REGEX_WEBSHELL_REQUEST_PATH = "^(" + SECURE_WEBSOCKET_PROTOCOL_STRING+"|"+WEBSOCKET_PROTOCOL_STRING + ")[^/]+/[^/]+/webshell$"; @@ -150,7 +158,8 @@ public Object createWebSocket(ServletUpgradeRequest req, // Upgrade happens here final ClientEndpointConfig clientConfig = getClientEndpointConfig(req); - clientConfig.getUserProperties().put("org.apache.knox.gateway.websockets.truststore", getTruststore()); + clientConfig.getUserProperties().put(TRUSTSTORE_USER_PROPERTY, getTruststore()); + configureClientIdentity(clientConfig.getUserProperties()); return new ProxyWebSocketAdapter(URI.create(backendURL), pool, clientConfig, config); } catch (final Exception e) { LOG.failedCreatingWebSocket(e); @@ -169,6 +178,38 @@ private KeyStore getTruststore() throws KeystoreServiceException { return trustKeystore; } + /** + * Mirrors DefaultHttpClientFactory#createSSLContext: when two-way SSL is + * enabled, select the client identity keystore (single-EKU aware) and add it, + * with its key passphrase, to the WebSocket client's user properties so the + * outbound TLS handshake can present a client certificate. + */ + void configureClientIdentity(final Map userProperties) + throws KeystoreServiceException, AliasServiceException { + if (!config.isHttpClientTwoWaySslEnabled()) { + return; + } + + final KeystoreService ks = this.services.getService(ServiceType.KEYSTORE_SERVICE); + final AliasService as = this.services.getService(ServiceType.ALIAS_SERVICE); + + final KeyStore identityKeystore; + final char[] identityKeyPassphrase; + if (config.isSingleEkuEnabled()) { + identityKeystore = ks.getKeystoreForHttpClient(); + identityKeyPassphrase = as.getHttpClientKeyPassphrase(); + } else { + identityKeystore = ks.getKeystoreForGateway(); + identityKeyPassphrase = as.getGatewayIdentityPassphrase(); + } + + if (identityKeystore != null) { + userProperties.put(KEYSTORE_USER_PROPERTY, identityKeystore); + userProperties.put(KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY, identityKeyPassphrase); + } else { + LOG.noClientIdentityForTwoWaySsl(); + } + } /** * Returns a {@link ClientEndpointConfig} config that contains the headers diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java index 37c94a6ac5..2774f69a35 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapter.java @@ -21,6 +21,7 @@ import java.net.URI; import java.util.List; import java.util.ArrayList; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -35,6 +36,7 @@ import org.apache.knox.gateway.config.GatewayConfig; import org.eclipse.jetty.io.RuntimeIOException; import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.websocket.api.BatchMode; import org.eclipse.jetty.websocket.api.RemoteEndpoint; import org.eclipse.jetty.websocket.api.Session; @@ -100,21 +102,17 @@ public void onWebSocketConnect(final Session frontEndSession) { container.setAsyncSendTimeout(frontEndSession.getPolicy().getAsyncWriteTimeout()); container.setDefaultMaxSessionIdleTimeout(frontEndSession.getPolicy().getIdleTimeout()); - KeyStore ks = null; - if(clientConfig != null) { - ks = (KeyStore) clientConfig.getUserProperties().get("org.apache.knox.gateway.websockets.truststore"); - } - /* Currently javax.websocket API has no provisions to configure SSL https://github.com/eclipse-ee4j/websocket-api/issues/210 Until that gets fixed we'll have to resort to this. */ - if(container instanceof org.eclipse.jetty.websocket.jsr356.ClientContainer && + if(clientConfig != null && + container instanceof org.eclipse.jetty.websocket.jsr356.ClientContainer && ((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient() != null && ((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient().getSslContextFactory() != null ) { - ((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient().getHttpClient().getSslContextFactory().setTrustStore(ks); - LOG.logMessage("Truststore for websocket setup"); + configureSsl(((org.eclipse.jetty.websocket.jsr356.ClientContainer)container).getClient().getHttpClient().getSslContextFactory(), clientConfig); + LOG.logMessage("SSL for websocket setup"); } final ProxyInboundClient backendSocket = new ProxyInboundClient(getMessageCallback()); @@ -160,6 +158,31 @@ public void onWebSocketConnect(final Session frontEndSession) { } } + /** + * Configures the WebSocket client's SslContextFactory from the values the + * handler placed in the ClientEndpointConfig user properties: the truststore + * (unchanged behavior, may be null) and, when two-way SSL supplied one, the + * client identity keystore plus its key-manager password. + */ + static void configureSsl(final SslContextFactory sslContextFactory, + final ClientEndpointConfig clientConfig) { + final Map userProperties = clientConfig.getUserProperties(); + + sslContextFactory.setTrustStore( + (KeyStore) userProperties.get(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY)); + + final KeyStore identityKeystore = + (KeyStore) userProperties.get(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY); + if (identityKeystore != null) { + sslContextFactory.setKeyStore(identityKeystore); + final char[] passphrase = + (char[]) userProperties.get(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY); + if (passphrase != null) { + sslContextFactory.setKeyManagerPassword(new String(passphrase)); + } + } + } + @Override public void onWebSocketBinary(final byte[] payload, final int offset, final int length) { if (isNotConnected()) { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java index fc8484ec3f..9e057e56d9 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/websockets/WebsocketLogMessages.java @@ -60,4 +60,9 @@ void failedCreatingWebSocket( text = "{0}") void debugLog(String message); + @Message(level = MessageLevel.WARN, + text = "Two-way SSL is enabled but no client identity keystore was found; " + + "the outbound WebSocket connection will not present a client certificate") + void noClientIdentityForTwoWaySsl(); + } 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..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 @@ -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,9 @@ 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 +org.apache.knox.gateway.services.factory.TrustedOidcIssuerServiceFactory diff --git a/gateway-server/src/main/resources/conf/gateway-site.xml b/gateway-server/src/main/resources/conf/gateway-site.xml index fda674c179..a44a81d7e8 100644 --- a/gateway-server/src/main/resources/conf/gateway-site.xml +++ b/gateway-server/src/main/resources/conf/gateway-site.xml @@ -56,6 +56,18 @@ limitations under the License. Base DN for LDAP entries in the proxy server. Default is dc=proxy,dc=com. + + gateway.ldap.max.size.limit + 1000 + Maximum number of entries returned by a search request. + + + + gateway.ldap.max.time.limit + 60000 + Maximum time for a search request in milliseconds. + + gateway.ldap.recursive.group.resolution false @@ -113,4 +125,53 @@ limitations under the License. Interceptor type. + + + \ No newline at end of file diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTable.sql new file mode 100644 index 0000000000..c74cf756ed --- /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..60e1e247b6 --- /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) NOT NULL, + attr_key VARCHAR(128) NOT NULL, + attr_value CLOB, + PRIMARY KEY (identity_id, attr_key), + CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE +) \ 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..9d89349aa9 --- /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..dd2b163a7d --- /dev/null +++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTable.sql @@ -0,0 +1,24 @@ +-- 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, + CONSTRAINT UX_FED_IDENTITY UNIQUE (provider, external_issuer, external_subject) +) \ No newline at end of file diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql new file mode 100644 index 0000000000..dd2b163a7d --- /dev/null +++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql @@ -0,0 +1,24 @@ +-- 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, + CONSTRAINT UX_FED_IDENTITY UNIQUE (provider, external_issuer, external_subject) +) \ No newline at end of file diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql new file mode 100644 index 0000000000..922d7b95a2 --- /dev/null +++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableOracle.sql @@ -0,0 +1,24 @@ +-- 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, + CONSTRAINT UX_FED_IDENTITY UNIQUE (provider, external_issuer, external_subject) +) \ 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/config/impl/GatewayConfigImplTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java index 0303150be3..9a337146bc 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java @@ -38,6 +38,7 @@ import java.security.KeyStore; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -825,4 +826,31 @@ public void testReloadableConfigLoading() throws Exception { System.clearProperty("KNOX_GATEWAY_CONF_DIR"); } } + + @Test + public void testSigningKeyAliases() { + GatewayConfigImpl config = new GatewayConfigImpl(); + + // Default: only the current signing key, so a single-key deployment is unchanged. + assertEquals(Collections.singletonList(config.getSigningKeyAlias()), config.getSigningKeyAliases()); + + // Additional aliases follow the current key, in order. + config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, "old-key-1, old-key-2"); + List aliases = config.getSigningKeyAliases(); + assertEquals(3, aliases.size()); + assertEquals(config.getSigningKeyAlias(), aliases.get(0)); + assertTrue(aliases.contains("old-key-1")); + assertTrue(aliases.contains("old-key-2")); + + // "none" disables additional aliases. + config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, "none"); + assertEquals(Collections.singletonList(config.getSigningKeyAlias()), config.getSigningKeyAliases()); + + // The current alias is never published/checked twice, even if listed as additional. + config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, config.getSigningKeyAlias() + ", old-key-1"); + aliases = config.getSigningKeyAliases(); + assertEquals(2, aliases.size()); + assertEquals(config.getSigningKeyAlias(), aliases.get(0)); + assertTrue(aliases.contains("old-key-1")); + } } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/AbstractGatewayServicesTest.java index ff58ad6f93..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 @@ -67,7 +67,9 @@ 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, + ServiceType.TRUSTED_OIDC_ISSUER_SERVICE }; assertNotEquals(ServiceType.values(), orderedServiceTypes); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java new file mode 100644 index 0000000000..996aae6cb2 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/FederatedIdentityServiceFactoryTest.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.knox.gateway.services.factory; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.io.FileUtils; +import org.apache.knox.gateway.config.impl.GatewayConfigImpl; +import org.apache.knox.gateway.database.DatabaseType; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.Service; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.knoxidf.federation.DerbyDBFederatedIdentityService; +import org.apache.knox.gateway.services.knoxidf.federation.EmptyFederatedIdentityService; +import org.apache.knox.gateway.services.knoxidf.federation.JdbcFederatedIdentityService; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.gateway.services.topology.TopologyService; +import org.apache.knox.gateway.topology.Topology; +import org.apache.knox.test.TestUtils; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Test; + +public class FederatedIdentityServiceFactoryTest { + + private final FederatedIdentityServiceFactory serviceFactory = new FederatedIdentityServiceFactory(); + private final Map options = new HashMap<>(); + private File tempDir; + private Service createdService; + + @After + public void tearDown() throws Exception { + if (createdService != null) { + createdService.stop(); + } + if (tempDir != null) { + FileUtils.forceDelete(tempDir); + } + } + + @Test + public void shouldChooseDerbyWhenNoDatabaseConfigured() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn("none").anyTimes(); + EasyMock.replay(config); + assertEquals(DerbyDBFederatedIdentityService.class.getName(), serviceFactory.chooseAutoImplementation(config)); + } + + @Test + public void shouldChooseDerbyWhenDatabaseTypeIsDerby() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.replay(config); + assertEquals(DerbyDBFederatedIdentityService.class.getName(), serviceFactory.chooseAutoImplementation(config)); + } + + @Test + public void shouldChooseJdbcWhenExternalDatabaseConfigured() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.POSTGRESQL.type()).anyTimes(); + EasyMock.replay(config); + assertEquals(JdbcFederatedIdentityService.class.getName(), serviceFactory.chooseAutoImplementation(config)); + } + + @Test + public void shouldDetectKnoxIdfFromInMemoryTopology() { + final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXIDF")); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.replay(config); + assertTrue(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config)); + } + + @Test + public void shouldDetectKnoxIdfAdminFromInMemoryTopology() { + final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXIDF_ADMIN")); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.replay(config); + assertTrue(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config)); + } + + @Test + public void shouldDetectKnoxIdfViaDiskScanWhenNoTopologiesLoadedYet() throws IOException { + // Mirrors the real init-time timing: the topology monitor has not loaded topologies yet, so the + // in-memory list is empty, but the topology XML already exists on disk. + tempDir = TestUtils.createTempDir(this.getClass().getName()); + writeTopologyFile("knoxidf-sso.xml", "KNOXIDF"); + + final GatewayServices gatewayServices = servicesWithTopology(/* no in-memory topologies */); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getGatewayTopologyDir()).andReturn(tempDir.getAbsolutePath()).anyTimes(); + EasyMock.replay(config); + + assertTrue(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config)); + } + + @Test + public void shouldNotDetectKnoxIdfWhenAbsentFromMemoryAndDisk() throws IOException { + tempDir = TestUtils.createTempDir(this.getClass().getName()); + writeTopologyFile("sandbox.xml", "KNOXSSO"); + + final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXSSO")); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getGatewayTopologyDir()).andReturn(tempDir.getAbsolutePath()).anyTimes(); + EasyMock.replay(config); + + assertFalse(serviceFactory.isKnoxIdfEnabledInAnyTopology(gatewayServices, config)); + } + + @Test + public void shouldHonorExplicitEmptyImplEvenWhenKnoxIdfIsDeployed() throws Exception { + final GatewayServices gatewayServices = servicesWithTopology(topologyWithRole("KNOXIDF")); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.replay(config); + + createdService = serviceFactory.create(gatewayServices, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options, + EmptyFederatedIdentityService.class.getName()); + assertTrue(createdService instanceof EmptyFederatedIdentityService); + } + + @Test + public void shouldSelectEmptyWhenKnoxIdfNotDeployed() throws Exception { + final GatewayServices gatewayServices = servicesWithTopology(/* no topologies */); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + // No topology dir and no in-memory topology -> KnoxIDF not enabled -> Empty. + EasyMock.replay(config); + + createdService = serviceFactory.create(gatewayServices, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options, ""); + assertTrue(createdService instanceof EmptyFederatedIdentityService); + } + + @Test + public void shouldAutoSelectDerbyServiceWhenKnoxIdfDeployedWithoutExternalDatabase() throws Exception { + tempDir = TestUtils.createTempDir(this.getClass().getName()); + final String masterSecret = "M4st3RSecret!"; + final MasterService masterService = EasyMock.createNiceMock(MasterService.class); + EasyMock.expect(masterService.getMasterSecret()).andReturn(masterSecret.toCharArray()).anyTimes(); + EasyMock.replay(masterService); + final AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.replay(aliasService); + + final GatewayServices gatewayServices = EasyMock.createNiceMock(GatewayServices.class); + final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class); + EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.singletonList(topologyWithRole("KNOXIDF"))).anyTimes(); + EasyMock.replay(topologyService); + EasyMock.expect(gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes(); + EasyMock.expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.expect(gatewayServices.getService(ServiceType.MASTER_SERVICE)).andReturn(masterService).anyTimes(); + EasyMock.replay(gatewayServices); + + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.expect(config.getGatewaySecurityDir()).andReturn(tempDir.getAbsolutePath()).anyTimes(); + EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(tempDir.getAbsolutePath(), "tokens").toString()).anyTimes(); + EasyMock.replay(config); + + createdService = serviceFactory.create(gatewayServices, ServiceType.KNOXIDF_FEDERATED_IDENTITY_SERVICE, config, options, ""); + assertTrue("Expected a self-provisioning Derby-backed federated identity service, got " + + createdService.getClass().getName(), createdService instanceof DerbyDBFederatedIdentityService); + } + + private Topology topologyWithRole(String role) { + final Topology topology = new Topology(); + topology.setName("topology-" + role); + final org.apache.knox.gateway.topology.Service service = new org.apache.knox.gateway.topology.Service(); + service.setRole(role); + topology.addService(service); + return topology; + } + + private GatewayServices servicesWithTopology(Topology... topologies) { + final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class); + EasyMock.expect(topologyService.getTopologies()).andReturn(Arrays.asList(topologies)).anyTimes(); + EasyMock.replay(topologyService); + final GatewayServices gatewayServices = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gatewayServices.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes(); + EasyMock.replay(gatewayServices); + return gatewayServices; + } + + private void writeTopologyFile(String name, String content) throws IOException { + Files.write(Paths.get(tempDir.getAbsolutePath(), name), content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java new file mode 100644 index 0000000000..ff06f78256 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/factory/TrustedOidcIssuerServiceFactoryTest.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

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

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.services.factory; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.io.FileUtils; +import org.apache.knox.gateway.config.impl.GatewayConfigImpl; +import org.apache.knox.gateway.database.DatabaseType; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.Service; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.DerbyDBTrustedOidcIssuerService; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.EmptyTrustedOidcIssuerService; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.JdbcTrustedOidcIssuerService; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.gateway.services.topology.TopologyService; +import org.apache.knox.gateway.topology.Topology; +import org.apache.knox.test.TestUtils; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Test; + +public class TrustedOidcIssuerServiceFactoryTest { + + private final TrustedOidcIssuerServiceFactory serviceFactory = new TrustedOidcIssuerServiceFactory(); + private final Map options = new HashMap<>(); + private File tempDir; + private Service createdService; + + @After + public void tearDown() throws Exception { + if (createdService != null) { + createdService.stop(); + } + if (tempDir != null) { + FileUtils.forceDelete(tempDir); + } + } + + // ------------------------------------------------------------------ + // Auto-implementation selection + // ------------------------------------------------------------------ + + @Test + public void shouldChooseDerbyWhenNoDatabaseConfigured() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn("none").anyTimes(); + EasyMock.replay(config); + assertEquals(DerbyDBTrustedOidcIssuerService.class.getName(), serviceFactory.chooseAutoImplementation(config)); + } + + @Test + public void shouldChooseDerbyWhenDatabaseTypeIsDerby() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.replay(config); + assertEquals(DerbyDBTrustedOidcIssuerService.class.getName(), serviceFactory.chooseAutoImplementation(config)); + } + + @Test + public void shouldChooseJdbcWhenExternalDatabaseConfigured() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.POSTGRESQL.type()).anyTimes(); + EasyMock.replay(config); + assertEquals(JdbcTrustedOidcIssuerService.class.getName(), serviceFactory.chooseAutoImplementation(config)); + } + + // ------------------------------------------------------------------ + // Empty (no KNOXIDF) cases + // ------------------------------------------------------------------ + + /** Zero topologies → KnoxIDF not deployed → Empty. */ + @Test + public void shouldSelectEmptyWhenNoTopologies() throws Exception { + final GatewayServices gws = servicesWithTopology(/* no topologies */); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.replay(config); + createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, ""); + assertTrue(createdService instanceof EmptyTrustedOidcIssuerService); + } + + /** Topologies exist but none contain KNOXIDF or KNOXIDF_ADMIN → Empty. */ + @Test + public void shouldSelectEmptyWhenNoKnoxIdfRole() throws Exception { + final GatewayServices gws = servicesWithTopology(topologyWithRole("KNOXSSO")); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.replay(config); + createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, ""); + assertTrue(createdService instanceof EmptyTrustedOidcIssuerService); + } + + /** An explicit Empty implementation is honored even when KnoxIDF is deployed. */ + @Test + public void shouldHonorExplicitEmptyImplEvenWhenKnoxIdfIsDeployed() throws Exception { + final GatewayServices gws = servicesWithTopology(topologyWithRole("KNOXIDF")); + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.replay(config); + createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, + EmptyTrustedOidcIssuerService.class.getName()); + assertTrue(createdService instanceof EmptyTrustedOidcIssuerService); + } + + // ------------------------------------------------------------------ + // Derby auto-provisioning + // ------------------------------------------------------------------ + + @Test + public void shouldAutoSelectDerbyServiceWhenKnoxIdfDeployedWithoutExternalDatabase() throws Exception { + tempDir = TestUtils.createTempDir(this.getClass().getName()); + final MasterService masterService = EasyMock.createNiceMock(MasterService.class); + EasyMock.expect(masterService.getMasterSecret()).andReturn("M4st3RSecret!".toCharArray()).anyTimes(); + EasyMock.replay(masterService); + final AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.replay(aliasService); + + final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class); + EasyMock.expect(topologyService.getTopologies()).andReturn(Collections.singletonList(topologyWithRole("KNOXIDF"))).anyTimes(); + EasyMock.replay(topologyService); + final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes(); + EasyMock.expect(gws.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.expect(gws.getService(ServiceType.MASTER_SERVICE)).andReturn(masterService).anyTimes(); + EasyMock.replay(gws); + + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.expect(config.getGatewaySecurityDir()).andReturn(tempDir.getAbsolutePath()).anyTimes(); + EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(tempDir.getAbsolutePath(), "tokens").toString()).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerMaxTrustedIssuers()).andReturn(10).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerDiscoveryCacheTtlSecs()).andReturn(300).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs()).andReturn(2000).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()).andReturn(2000).anyTimes(); + EasyMock.replay(config); + + createdService = serviceFactory.create(gws, ServiceType.TRUSTED_OIDC_ISSUER_SERVICE, config, options, ""); + assertNotNull(createdService); + assertTrue("Expected a self-provisioning Derby-backed trusted OIDC issuer service, got " + + createdService.getClass().getName(), createdService instanceof DerbyDBTrustedOidcIssuerService); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private Topology topologyWithRole(String role) { + final Topology topology = new Topology(); + topology.setName("topology-" + role); + final org.apache.knox.gateway.topology.Service service = new org.apache.knox.gateway.topology.Service(); + service.setRole(role); + topology.addService(service); + return topology; + } + + private GatewayServices servicesWithTopology(Topology... topologies) { + final TopologyService topologyService = EasyMock.createNiceMock(TopologyService.class); + EasyMock.expect(topologyService.getTopologies()).andReturn(Arrays.asList(topologies)).anyTimes(); + EasyMock.replay(topologyService); + final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gws.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(topologyService).anyTimes(); + EasyMock.replay(gws); + return gws; + } +} diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java new file mode 100644 index 0000000000..370e987b73 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/federation/DerbyDBFederatedIdentityServiceTest.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.apache.commons.io.FileUtils; +import org.apache.knox.gateway.config.impl.GatewayConfigImpl; +import org.apache.knox.gateway.database.DatabaseType; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.test.TestUtils; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that {@link DerbyDBFederatedIdentityService} self-provisions an embedded Derby database + * and round-trips a federated identity through it. + */ +public class DerbyDBFederatedIdentityServiceTest { + + private File securityDir; + private DerbyDBFederatedIdentityService service; + + @Before + public void setUp() throws IOException { + securityDir = TestUtils.createTempDir(this.getClass().getName()); + } + + @After + public void tearDown() throws Exception { + if (service != null) { + service.stop(); + } + if (securityDir != null) { + FileUtils.forceDelete(securityDir); + } + } + + @Test + public void shouldRoundTripAFederatedIdentityOnEmbeddedDerby() throws Exception { + final String masterSecret = "M4st3RSecret!"; + final MasterService masterService = EasyMock.createNiceMock(MasterService.class); + EasyMock.expect(masterService.getMasterSecret()).andReturn(masterSecret.toCharArray()).anyTimes(); + EasyMock.replay(masterService); + + final AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.replay(aliasService); + + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir.getAbsolutePath()).anyTimes(); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(securityDir.getAbsolutePath(), "tokens").toString()).anyTimes(); + EasyMock.replay(config); + + service = new DerbyDBFederatedIdentityService(); + service.setAliasService(aliasService); + service.setMasterService(masterService); + service.init(config, Collections.emptyMap()); + + final Map attributes = new HashMap<>(); + attributes.put("email", "alice@example.com"); + final FederatedIdentity identity = new FederatedIdentity("knox-user-1", "KEYCLOAK", "external-subject-1", + "https://issuer.example.com/realms/knox", Instant.now(), attributes); + service.addFederatedIdentity(identity); + + final Optional byId = service.findById(identity.getId()); + assertTrue("Expected the identity to be found by id", byId.isPresent()); + assertEquals("KEYCLOAK", byId.get().getProvider()); + assertEquals("alice@example.com", byId.get().getAttribute("email")); + + final Optional byProviderAndSubject = service.findByProviderAndSubject( + "KEYCLOAK", "https://issuer.example.com/realms/knox", "external-subject-1"); + assertTrue("Expected the identity to be found by provider/issuer/subject", byProviderAndSubject.isPresent()); + assertEquals(identity.getId(), byProviderAndSubject.get().getId()); + + final Optional missing = service.findByProviderAndSubject( + "KEYCLOAK", "https://issuer.example.com/realms/knox", "no-such-subject"); + assertFalse("Did not expect an identity for an unknown subject", missing.isPresent()); + } + + /** + * Regression guard for the "Table/View 'FEDERATED_IDENTITY' already exists" failure on restart: + * re-initialising against the same on-disk Derby database (as happens on a Knox restart) must not + * try to re-create the already-present tables. Before the {@code JDBCUtils.tableExists} casing + * fix, the lowercase {@code federated_identity} table name never matched Derby's uppercased + * metadata, so init re-ran the CREATE and blew up on the second boot. + */ + @Test + public void shouldReinitializeWithoutErrorWhenTablesAlreadyExist() throws Exception { + service = newDerbyService(); + final FederatedIdentity identity = new FederatedIdentity("knox-user-1", "KEYCLOAK", "external-subject-1", + "https://issuer.example.com/realms/knox", Instant.now(), new HashMap<>()); + service.addFederatedIdentity(identity); + service.stop(); + + // Simulate a restart: a brand-new service instance pointing at the same Derby folder. + service = newDerbyService(); + final Optional byId = service.findById(identity.getId()); + assertTrue("Expected the previously-persisted identity to survive a restart", byId.isPresent()); + } + + /** + * Regression guard for the concurrent-first-login phantom-id race: two callbacks for the same + * external identity each build a FederatedIdentity with a distinct random primary key. The first + * wins the insert; the second violates UNIQUE(provider, external_issuer, external_subject). + * addFederatedIdentity must return the already-stored row (the winner's id), never the losing + * thread's in-memory object whose random id was never persisted -- otherwise the downstream auth + * code is keyed to an id absent from the table, surfacing later as a token-exchange 500. + */ + @Test + public void shouldReturnPersistedRowWhenExternalIdentityAlreadyExists() throws Exception { + service = newDerbyService(); + + final FederatedIdentity first = new FederatedIdentity("knox-user-1", "KEYCLOAK", "external-subject-1", + "https://issuer.example.com/realms/knox", Instant.now(), new HashMap<>()); + final FederatedIdentity firstStored = service.addFederatedIdentity(first); + assertSame("Happy path must return the same instance it persisted (no re-query)", first, firstStored); + + // The losing thread of a concurrent first login: same external identity, brand-new object with a + // different random primary key that never gets persisted. + final FederatedIdentity duplicate = new FederatedIdentity("knox-user-1", "KEYCLOAK", "external-subject-1", + "https://issuer.example.com/realms/knox", Instant.now(), new HashMap<>()); + assertNotEquals("Test setup: the duplicate must carry a different random id", + first.getId(), duplicate.getId()); + + final FederatedIdentity stored = service.addFederatedIdentity(duplicate); + assertEquals("On unique-constraint conflict the canonical persisted id must be returned", + first.getId(), stored.getId()); + assertFalse("The losing thread's random id must not exist in the table", + service.findById(duplicate.getId()).isPresent()); + } + + private DerbyDBFederatedIdentityService newDerbyService() throws Exception { + final MasterService masterService = EasyMock.createNiceMock(MasterService.class); + EasyMock.expect(masterService.getMasterSecret()).andReturn("M4st3RSecret!".toCharArray()).anyTimes(); + EasyMock.replay(masterService); + + final AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.replay(aliasService); + + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir.getAbsolutePath()).anyTimes(); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(securityDir.getAbsolutePath(), "tokens").toString()).anyTimes(); + EasyMock.replay(config); + + final DerbyDBFederatedIdentityService svc = new DerbyDBFederatedIdentityService(); + svc.setAliasService(aliasService); + svc.setMasterService(masterService); + svc.init(config, Collections.emptyMap()); + return svc; + } +} diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java new file mode 100644 index 0000000000..f2bc4f0815 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/DerbyDBTrustedOidcIssuerServiceTest.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.io.FileUtils; +import org.apache.knox.gateway.config.impl.GatewayConfigImpl; +import org.apache.knox.gateway.database.DatabaseType; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.MasterService; +import org.apache.knox.test.TestUtils; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that {@link DerbyDBTrustedOidcIssuerService} self-provisions an embedded Derby database + * and round-trips a trusted OIDC issuer through it. + */ +public class DerbyDBTrustedOidcIssuerServiceTest { + + private File securityDir; + private DerbyDBTrustedOidcIssuerService service; + + @Before + public void setUp() throws IOException { + securityDir = TestUtils.createTempDir(this.getClass().getName()); + } + + @After + public void tearDown() throws Exception { + if (service != null) { + service.stop(); + } + if (securityDir != null) { + FileUtils.forceDelete(securityDir); + } + } + + @Test + public void shouldRoundTripATrustedIssuerOnEmbeddedDerby() throws Exception { + service = newService(newConfig()); + + final TrustedOidcIssuer issuer = new TrustedOidcIssuer( + "https://issuer.example.com/realms/knox", true, "clusterA", Instant.now(), "admin"); + service.register(issuer); + + assertTrue("Expected the registered issuer to be trusted", service.isTrusted(issuer.getIssuerUrl())); + assertTrue("Expected the registered issuer to be dynamic-jwks", service.isDynamicJwks(issuer.getIssuerUrl())); + assertFalse("Did not expect an unknown issuer to be trusted", service.isTrusted("https://unknown.example.com")); + + final List all = service.list(); + assertEquals(1, all.size()); + assertEquals(issuer.getIssuerUrl(), all.get(0).getIssuerUrl()); + + service.deregister(issuer.getIssuerUrl()); + assertFalse("Expected the issuer to be gone after deregister", service.isTrusted(issuer.getIssuerUrl())); + } + + /** + * Regression guard for the "Table/View 'TRUSTED_OIDC_ISSUERS' already exists" failure on restart: + * re-initialising against the same on-disk Derby database (as happens on a Knox restart) must not + * try to re-create the already-present table. + */ + @Test + public void shouldReinitializeWithoutErrorWhenTableAlreadyExists() throws Exception { + service = newService(newConfig()); + final TrustedOidcIssuer issuer = new TrustedOidcIssuer( + "https://issuer.example.com/realms/knox", false, null, Instant.now(), "admin"); + service.register(issuer); + service.stop(); + + // Simulate a restart: a brand-new service instance pointing at the same Derby folder. + service = newService(newConfig()); + assertTrue("Expected the previously-registered issuer to survive a restart", + service.isTrusted(issuer.getIssuerUrl())); + } + + private DerbyDBTrustedOidcIssuerService newService(GatewayConfigImpl config) throws Exception { + final MasterService masterService = EasyMock.createNiceMock(MasterService.class); + EasyMock.expect(masterService.getMasterSecret()).andReturn("M4st3RSecret!".toCharArray()).anyTimes(); + EasyMock.replay(masterService); + + final AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.replay(aliasService); + + final DerbyDBTrustedOidcIssuerService svc = new DerbyDBTrustedOidcIssuerService(); + svc.setAliasService(aliasService); + svc.setMasterService(masterService); + svc.init(config, Collections.emptyMap()); + return svc; + } + + private GatewayConfigImpl newConfig() { + final GatewayConfigImpl config = EasyMock.createNiceMock(GatewayConfigImpl.class); + EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir.getAbsolutePath()).anyTimes(); + EasyMock.expect(config.getDatabaseType()).andReturn(DatabaseType.DERBY.type()).anyTimes(); + EasyMock.expect(config.getDatabaseName()).andReturn(Paths.get(securityDir.getAbsolutePath(), "tokens").toString()).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerMaxTrustedIssuers()).andReturn(10).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerDiscoveryCacheTtlSecs()).andReturn(300).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerDiscoveryConnectTimeoutMs()).andReturn(2000).anyTimes(); + EasyMock.expect(config.getTrustedOidcIssuerDiscoveryReadTimeoutMs()).andReturn(2000).anyTimes(); + EasyMock.replay(config); + return config; + } +} diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/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..5e8aa1cc8c --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerServiceTest.java @@ -0,0 +1,419 @@ +/* + * 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.lang3.reflect.FieldUtils; +import org.apache.knox.gateway.config.GatewayConfig; +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 java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +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 GatewayConfig gatewayConfig; + private 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(); + } + + @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 Exception { + // 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 + } + + 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); + } + + // ------------------------------------------------------------------ + // 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 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); + 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()); + + 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 + 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 + } + + // ------------------------------------------------------------------ + // 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 + // ------------------------------------------------------------------ + + @Test(expected = ServiceLifecycleException.class) + public void testInitFailsWithoutAliasService() throws ServiceLifecycleException { + final JdbcTrustedOidcIssuerService noAliasService = new JdbcTrustedOidcIssuerService(); + // setAliasService NOT called + noAliasService.init(gatewayConfig, null); + } + + /** + * Review finding M5: init() must re-check {@code initialized} inside the lock so a thread that + * blocked while another was initialising does not re-initialise (overwriting the already-built + * database/discoveryHelper). This deterministically reproduces the race window: the test thread + * holds the init lock and marks the service initialised (as a "winning" thread would) while a + * second init() call is blocked entering the critical section; when it proceeds, the inner recheck + * must make it a no-op and leave the sentinel database reference untouched. + */ + @Test + public void testConcurrentInitDoesNotReinitialize() throws Exception { + final ReentrantLock initLock = (ReentrantLock) FieldUtils.readField(service, "initLock", true); + final AtomicBoolean initialized = (AtomicBoolean) FieldUtils.readField(service, "initialized", true); + + // A sentinel that the losing init() must NOT overwrite if the inner recheck is present. + final TrustedOidcIssuerDatabase sentinel = EasyMock.createNiceMock(TrustedOidcIssuerDatabase.class); + FieldUtils.writeField(service, "database", sentinel, true); + + // Simulate the pre-init state a second racing thread would have observed at the outer check. + initialized.set(false); + + // Hold the lock first, then start the racing init(): it passes the outer !initialized check and + // blocks entering the critical section until we release the lock. + initLock.lock(); + final AtomicBoolean workerFailed = new AtomicBoolean(false); + final Thread worker = new Thread(() -> { + try { + service.init(gatewayConfig, null); + } catch (Exception e) { + workerFailed.set(true); + } + }); + try { + worker.start(); + // Wait until the worker is actually blocked in the lock queue, i.e. it entered init() while + // initialized was still false (the exact race the inner recheck must defend against). + final long deadline = System.currentTimeMillis() + 10_000; + while (!initLock.hasQueuedThreads() && System.currentTimeMillis() < deadline) { + Thread.yield(); + } + assertTrue("Worker should be blocked entering the init critical section", initLock.hasQueuedThreads()); + // Mimic the winning thread finishing initialisation while we hold the lock. + initialized.set(true); + } finally { + initLock.unlock(); + } + worker.join(10_000); + + assertFalse("Racing init() must not have thrown", workerFailed.get()); + assertFalse("Worker thread must have finished", worker.isAlive()); + assertSame("A second init() must not rebuild the database once initialised (inner recheck)", + sentinel, FieldUtils.readField(service, "database", true)); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + 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/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..8645017cc5 --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/TrustedOidcIssuersSchemaTest.java @@ -0,0 +1,123 @@ +/* + * 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 { + // 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); + } + + @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-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java index 327d701c30..a5e7df664d 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java @@ -465,6 +465,27 @@ public void testGetUserGroupsIgnoresBareRdnWhenRolesLookupInactive() throws Exce List.of("analysts"), groups); } + @Test + public void testStartSetsMaxSizeAndTime() throws Exception { + final int expectedMaxSize = 3158; + final int expectedMaxTime = 245000; + + GatewayConfig mockConfig = EasyMock.createNiceMock(GatewayConfig.class); + expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes(); + expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes(); + expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes(); + expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of()).anyTimes(); + expect(mockConfig.getLDAPMaxSizeLimit()).andReturn(expectedMaxSize).anyTimes(); + expect(mockConfig.getLDAPMaxTimeLimit()).andReturn(expectedMaxTime).anyTimes(); + replay(mockConfig); + + serverManager.initialize(mockConfig); + serverManager.start(); + + assertEquals(expectedMaxSize, serverManager.ldapServer.getMaxSizeLimit()); + assertEquals(expectedMaxTime, serverManager.ldapServer.getMaxTimeLimit()); + } + @Test(expected = LdapException.class) public void testBindRequiredRejectsAnonymous() throws Exception { useBindPassword(BIND_PASSWORD); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java index ce681a5617..f32c036f84 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java @@ -180,6 +180,8 @@ private void setupMockConfig(String backendType) throws Exception { expect(mockConfig.getLDAPBindUser()).andReturn(null).anyTimes(); expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("testbackend")).atLeastOnce(); expect(mockConfig.getLDAPInterceptorConfig("testbackend")).andReturn(buildBackendConfig(backendType)).atLeastOnce(); + expect(mockConfig.getLDAPMaxSizeLimit()).andReturn(1000).atLeastOnce(); + expect(mockConfig.getLDAPMaxTimeLimit()).andReturn(60000).atLeastOnce(); replay(mockConfig); } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java index 96b8e5a8f3..3ae14d812f 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java @@ -158,7 +158,8 @@ public void testGetUserOverLdaps() throws Exception { assertEquals("ldaptest1", entry.get("uid").getString()); validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org", + "cn=group3,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -168,6 +169,7 @@ public void testGetUserGroupsOverLdaps() throws Exception { List groups = ldapProxyBackend.getUserGroups("ldaptest1", schemaManager); assertTrue(groups.contains("group1")); assertTrue(groups.contains("group2")); + assertTrue(groups.contains("group3")); } @Test diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java index 58432f1374..7264527db8 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendTest.java @@ -25,6 +25,7 @@ import org.apache.directory.api.ldap.model.entry.Entry; import org.apache.directory.api.ldap.model.entry.Value; +import org.apache.directory.api.ldap.model.message.SearchRequest; import org.apache.directory.api.ldap.model.message.SearchScope; import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; @@ -37,6 +38,8 @@ import org.apache.directory.server.core.factory.PartitionFactory; import org.apache.directory.server.core.partition.ldif.LdifPartition; import org.apache.directory.server.ldap.LdapServer; +import org.apache.directory.server.ldap.LdapSession; +import org.apache.directory.server.ldap.handlers.LdapRequestHandler; import org.apache.directory.server.protocol.shared.store.LdifFileLoader; import org.apache.directory.server.protocol.shared.transport.TcpTransport; import org.apache.knox.gateway.security.ldap.SimpleDirectoryService; @@ -47,6 +50,8 @@ import org.junit.Test; import java.io.File; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -57,12 +62,15 @@ import java.util.concurrent.atomic.AtomicInteger; public class LdapProxyBackendTest { + private static final int PAGE_SIZE = 2; + private static Map ldapBackendConfig; private static TcpTransport transport; private static DirectoryService directoryService; private static LdapServer ldapServer; private static SchemaManager schemaManager; + private static CapturingSearchRequestHandler capturingSearchRequestHandler; private LdapProxyBackend ldapProxyBackend; @@ -110,10 +118,19 @@ public static void setupBeforeClass() throws Exception { // Create and start the LDAP server ldapServer = new LdapServer(); + ldapServer.setTransports(transport); ldapServer.setDirectoryService(directoryService); + ldapServer.start(); + capturingSearchRequestHandler = new CapturingSearchRequestHandler(ldapServer.getSearchRequestHandler()); + ldapServer.setSearchHandlers( + capturingSearchRequestHandler, + ldapServer.getSearchResultEntryHandler(), + ldapServer.getSearchResultReferenceHandler(), + ldapServer.getSearchResultDoneHandler()); + // Setup common backend config values for tests ldapBackendConfig = Map.of( "baseDn", "dc=hadoop,dc=apache,dc=org", @@ -143,6 +160,7 @@ public static void tearDownAfterClass() throws Exception { @After public void tearDown() throws Exception { + capturingSearchRequestHandler.reset(); if (ldapProxyBackend != null) { ldapProxyBackend.close(); } @@ -157,7 +175,8 @@ public void testGetUserByDefaultUserSearchFilter() throws Exception { validateUserEntry(entry, "ldaptest1", "TestCn1", "ldaptest1@example.com", "Test user ldaptest1"); validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org", + "cn=group3,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -177,7 +196,8 @@ public void testGetUserByUID() throws Exception { validateUserEntry(entry, "ldaptest1", "TestCn1", "ldaptest1@example.com", "Test user ldaptest1"); validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org", + "cn=group3,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -189,7 +209,8 @@ public void testGetUserByCN() throws Exception { validateUserEntry(entry, "ldaptest1", "TestCn1", "ldaptest1@example.com", "Test user ldaptest1"); validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org", + "cn=group3,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -211,7 +232,8 @@ public void testGetUserBySAMAccountName() throws Exception { assertEquals("TestSam1", entry.get("sAMAccountName").getString()); validateMemberOf(entry, Set.of( "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org", - "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org")); + "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org", + "cn=group3,ou=groups,dc=hadoop,dc=apache,dc=org")); } @Test @@ -230,8 +252,8 @@ public void testGetUserUseMemberOf() throws Exception { config.put("useMemberOf", "true"); ldapProxyBackend = new LdapProxyBackend("testbackend", config); - Entry entry = ldapProxyBackend.getUser("ldaptest2", schemaManager); - validateUserEntry(entry, "ldaptest2", "TestCn2", "ldaptest2@example.com", "Test user ldaptest2"); + Entry entry = ldapProxyBackend.getUser("ldapmemberof", schemaManager); + validateUserEntry(entry, "ldapmemberof", "TestMemberOf", "ldapmemberof@example.com", "Test user ldapmemberof"); validateMemberOf(entry, Set.of( "cn=groupMemberOf1,ou=groups,dc=hadoop,dc=apache,dc=org", "cn=groupMemberOf2,ou=groups,dc=hadoop,dc=apache,dc=org")); @@ -244,6 +266,38 @@ public void testGetUserGroups() throws Exception { List userGroups = ldapProxyBackend.getUserGroups("ldaptest1", schemaManager); assertTrue(userGroups.contains("group1")); assertTrue(userGroups.contains("group2")); + assertTrue(userGroups.contains("group3")); + } + + @Test + public void testGetUserGroupsPaging() throws Exception { + Map config = new HashMap<>(ldapBackendConfig); + config.put("pageSize", Integer.toString(PAGE_SIZE)); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); + + List userGroups = ldapProxyBackend.getUserGroups("ldaptest1", schemaManager); + assertEquals(3, userGroups.size()); + int matchingRequests = (int) capturingSearchRequestHandler.getRequests().stream() + .filter(request -> request.getBase().getName().equals("ou=groups,dc=hadoop,dc=apache,dc=org") && + request.getFilter().toString().contains("ldaptest1")) + .count(); + assertEquals(2, matchingRequests); + } + + @Test + public void testGetUserGroupsPagingExceedsMaxResultSetSize() throws Exception { + Map config = new HashMap<>(ldapBackendConfig); + config.put("pageSize", Integer.toString(PAGE_SIZE)); + config.put("maxResultSetSize", "1"); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); + + List userGroups = ldapProxyBackend.getUserGroups("ldaptest1", schemaManager); + assertEquals(PAGE_SIZE, userGroups.size()); // only retrieve 1 page because that will exceed the maxResultSetSize + int matchingRequests = (int) capturingSearchRequestHandler.getRequests().stream() + .filter(request -> request.getBase().getName().equals("ou=groups,dc=hadoop,dc=apache,dc=org") && + request.getFilter().toString().contains("ldaptest1")) + .count(); + assertEquals(1, matchingRequests); } @Test @@ -268,7 +322,7 @@ public void testGetUserGroupsUseMemberOf() throws Exception { config.put("useMemberOf", "true"); ldapProxyBackend = new LdapProxyBackend("testbackend", config); - List userGroups = ldapProxyBackend.getUserGroups("ldaptest2", schemaManager); + List userGroups = ldapProxyBackend.getUserGroups("ldapmemberof", schemaManager); assertTrue(userGroups.contains("groupMemberOf1")); assertTrue(userGroups.contains("groupMemberOf2")); } @@ -323,13 +377,42 @@ public void testGetUserGroupsUseMemberOfRecursiveDepth2() throws Exception { @Test public void testSearchUsers() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateUserSearch("*", 3, Set.of("ldaptest1", "ldaptest2", "guest")); + validateUserSearch("*", 4, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "guest")); + } + + @Test + public void testSearchUsersWithPaging() throws Exception { + Map config = new HashMap<>(ldapBackendConfig); + config.put("pageSize", Integer.toString(PAGE_SIZE)); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); + validateUserSearch("*", 4, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "guest")); + int matchingRequests = (int) capturingSearchRequestHandler.getRequests().stream() + .filter(request -> request.getBase().getName().equals("ou=people,dc=hadoop,dc=apache,dc=org") && + request.getFilter().toString().contains("uid=*")) + .count(); + assertEquals(2, matchingRequests); + } + + @Test + public void testSearchUsersWithPagingExceedsMaxResultSetSize() throws Exception { + Map config = new HashMap<>(ldapBackendConfig); + config.put("pageSize", Integer.toString(PAGE_SIZE)); + config.put("maxResultSetSize", "1"); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); + + List entries = ldapProxyBackend.searchUsers("*", schemaManager); + assertEquals(PAGE_SIZE, entries.size()); // only expect 1 page of results + int matchingRequests = (int) capturingSearchRequestHandler.getRequests().stream() + .filter(request -> request.getBase().getName().equals("ou=people,dc=hadoop,dc=apache,dc=org") && + request.getFilter().toString().contains("uid=*")) + .count(); + assertEquals(1, matchingRequests); } @Test public void testSearchUsersPartial() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateUserSearch("ldap*", 2, Set.of("ldaptest1", "ldaptest2")); + validateUserSearch("ldap*", 3, Set.of("ldaptest1", "ldaptest2", "ldapmemberof")); } @Test @@ -343,7 +426,7 @@ public void testSearchUsersNoneFound() throws Exception { public void testSearchUsersByCn() throws Exception { Map config = createConfigWithUserAttr("cn"); ldapProxyBackend = new LdapProxyBackend("testbackend", config); - validateUserSearch("*", 4, Set.of("ldaptest1", "ldaptest2", "Guest", "TestCn3")); + validateUserSearch("*", 5, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "Guest", "TestCn3")); } @Test @@ -365,7 +448,7 @@ public void testSearchUsersNoneFoundByCn() throws Exception { public void testSearchUsersBySAMAccountName() throws Exception { Map config = createConfigWithUserAttr("sAMAccountName"); ldapProxyBackend = new LdapProxyBackend("testbackend", config); - validateUserSearch("*", 3, Set.of("ldaptest1", "ldaptest2", "TestSam3")); + validateUserSearch("*", 4, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "TestSam3")); } @Test @@ -451,7 +534,7 @@ public void testSearchRecursiveWithSharedGroups() throws Exception { @Test public void testSearchObjectClassInetOrgPerson() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(objectClass=inetOrgPerson)", 4, Set.of("ldaptest1", "ldaptest2", "guest", "TestCn3")); + validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(objectClass=inetOrgPerson)", 5, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "guest", "TestCn3")); } @Test @@ -463,19 +546,47 @@ public void testSearchByUid() throws Exception { @Test public void testSearchByUidWildcard() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(uid=*)", 3, Set.of("ldaptest1", "ldaptest2", "guest")); + validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(uid=*)", 4, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "guest")); + } + + @Test + public void testSearchWithPaging() throws Exception { + Map config = new HashMap<>(ldapBackendConfig); + config.put("pageSize", Integer.toString(PAGE_SIZE)); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); + validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(uid=*)", 4, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "guest")); + int matchingRequests = (int) capturingSearchRequestHandler.getRequests().stream() + .filter(request -> request.getBase().getName().equals("ou=people,dc=hadoop,dc=apache,dc=org") && + request.getFilter().toString().contains("uid=*")) + .count(); + assertEquals(2, matchingRequests); + } + + @Test + public void testSearchWithPagingExceedsMaxResultSize() throws Exception { + Map config = new HashMap<>(ldapBackendConfig); + config.put("pageSize", Integer.toString(PAGE_SIZE)); + config.put("maxResultSetSize", "1"); + ldapProxyBackend = new LdapProxyBackend("testbackend", config); + List entries = ldapProxyBackend.search("ou=people,dc=hadoop,dc=apache,dc=org", SearchScope.SUBTREE, "(uid=*)", schemaManager); + assertEquals(PAGE_SIZE, entries.size()); // only expect 1 page because that will exceed the maxResultSetSize + int matchingRequests = (int) capturingSearchRequestHandler.getRequests().stream() + .filter(request -> request.getBase().getName().equals("ou=people,dc=hadoop,dc=apache,dc=org") && + request.getFilter().toString().contains("uid=*")) + .count(); + assertEquals(1, matchingRequests); } @Test public void testSearchByUidSubstringWildcard() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(uid=ldap*)", 2, Set.of("ldaptest1", "ldaptest2")); + validateSearch("ou=people,dc=hadoop,dc=apache,dc=org", "(uid=ldap*)", 3, Set.of("ldaptest1", "ldaptest2", "ldapmemberof")); } @Test public void testSearchObjectClassGroupOfNames() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("ou=groups,dc=hadoop,dc=apache,dc=org", "(objectClass=groupOfNames)", 3, Set.of("group1", "group2", "nameddifferently")); + validateSearch("ou=groups,dc=hadoop,dc=apache,dc=org", "(objectClass=groupOfNames)", 4, Set.of("group1", "group2", "group3", "nameddifferently")); } @Test @@ -487,19 +598,19 @@ public void testSearchByCn() throws Exception { @Test public void testSearchByCnWildcard() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("ou=groups,dc=hadoop,dc=apache,dc=org", "(cn=*)", 3, Set.of("group1", "group2", "nameddifferently")); + validateSearch("ou=groups,dc=hadoop,dc=apache,dc=org", "(cn=*)", 4, Set.of("group1", "group2", "group3", "nameddifferently")); } @Test public void testSearchByCnWSubstringildcard() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("ou=groups,dc=hadoop,dc=apache,dc=org", "(cn=group*)", 2, Set.of("group1", "group2")); + validateSearch("ou=groups,dc=hadoop,dc=apache,dc=org", "(cn=group*)", 3, Set.of("group1", "group2", "group3")); } @Test public void testSearchByUidOrCnWildcard() throws Exception { ldapProxyBackend = new LdapProxyBackend("testbackend", ldapBackendConfig); - validateSearch("dc=hadoop,dc=apache,dc=org", "(|(uid=ldap*)(cn=group*))", 4, Set.of("ldaptest1", "ldaptest2", "group1", "group2")); + validateSearch("dc=hadoop,dc=apache,dc=org", "(|(uid=ldap*)(cn=group*))", 6, Set.of("ldaptest1", "ldaptest2", "ldapmemberof", "group1", "group2", "group3")); } @Test @@ -809,4 +920,27 @@ public Set get(Object key) { // For the second user, many groups should have been found in the cache. assertEquals("Expected " + expectedCacheHits + " cache hits for shared groups, but got " + cacheHits.get(), expectedCacheHits, cacheHits.get()); } + + private static class CapturingSearchRequestHandler extends LdapRequestHandler { + private final LdapRequestHandler delegate; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + + CapturingSearchRequestHandler(LdapRequestHandler delegate) { + this.delegate = delegate; + } + + public void reset() { + requests.clear(); + } + + public List getRequests() { + return List.copyOf(requests); + } + + @Override + public void handle(LdapSession session, SearchRequest message) throws Exception { + requests.add(message); + delegate.handle(session, message); + } + } } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java index a11db4f103..4682db8f54 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java @@ -18,15 +18,21 @@ package org.apache.knox.gateway.services.token.impl; import java.io.File; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; import java.security.Principal; import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Optional; +import com.nimbusds.jose.crypto.RSASSASigner; import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.services.ServiceLifecycleException; import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.services.security.MasterService; import org.apache.knox.gateway.services.security.impl.DefaultKeystoreService; import org.apache.knox.gateway.services.security.token.impl.JWT; @@ -34,6 +40,8 @@ import org.apache.knox.gateway.services.security.token.JWTokenAttributes; import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder; import org.apache.knox.gateway.services.security.token.TokenServiceException; +import org.apache.knox.gateway.services.security.token.TokenUtils; +import org.apache.knox.gateway.util.X509CertificateUtil; import org.easymock.EasyMock; import org.junit.Test; @@ -592,6 +600,62 @@ public void testServiceInvalidKeyPassword() throws Exception { EasyMock.verify(config, ms, as); } + /** + * A token signed by a rotated-out key still verifies as long as that key's alias remains + * configured as an additional signing-key alias: verification selects the key by the token's + * {@code kid} header. Without that alias configured (single-key), the same token is rejected. + */ + @Test + public void testVerifyTokenSelectsKeyByKidAcrossRotation() throws Exception { + final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + final KeyPair currentPair = kpg.generateKeyPair(); + final KeyPair rotatedOutPair = kpg.generateKeyPair(); + + final KeyStore signingKeystore = KeyStore.getInstance("JKS"); + signingKeystore.load(null, null); + signingKeystore.setCertificateEntry("gateway-identity", + X509CertificateUtil.generateCertificate("CN=current", currentPair, 365, "SHA256withRSA")); + signingKeystore.setCertificateEntry("old-signing-key", + X509CertificateUtil.generateCertificate("CN=old", rotatedOutPair, 365, "SHA256withRSA")); + + final KeystoreService ks = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(ks.getSigningKeystore()).andReturn(signingKeystore).anyTimes(); + final AliasService as = EasyMock.createNiceMock(AliasService.class); + + // A token signed by the rotated-out key, stamped with that key's kid (as issueToken would). + final String rotatedOutKid = TokenUtils.getThumbprint((RSAPublicKey) rotatedOutPair.getPublic(), "SHA-256"); + final JWT token = new JWTToken(new JWTokenAttributesBuilder().setAlgorithm("RS256").setKid(rotatedOutKid) + .setAudiences(Collections.emptyList()).build()); + token.sign(new RSASSASigner(rotatedOutPair.getPrivate(), true)); + + // (1) Rotated-out alias still configured -> kid selection picks it -> verifies. + final GatewayConfig multiKeyConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(multiKeyConfig.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes(); + EasyMock.expect(multiKeyConfig.getSigningKeyAliases()) + .andReturn(Arrays.asList("gateway-identity", "old-signing-key")).anyTimes(); + EasyMock.replay(ks, as, multiKeyConfig); + + DefaultTokenAuthorityService ta = new DefaultTokenAuthorityService(); + ta.setKeystoreService(ks); + ta.setAliasService(as); + ta.init(multiKeyConfig, new HashMap<>()); + assertTrue("Token signed by a still-configured rotated key must verify", ta.verifyToken(token)); + + // (2) Only the current key configured (single-key) -> the rotated key's token is rejected. + final GatewayConfig singleKeyConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(singleKeyConfig.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes(); + EasyMock.expect(singleKeyConfig.getSigningKeyAliases()) + .andReturn(Collections.singletonList("gateway-identity")).anyTimes(); + EasyMock.replay(singleKeyConfig); + + ta = new DefaultTokenAuthorityService(); + ta.setKeystoreService(ks); + ta.setAliasService(as); + ta.init(singleKeyConfig, new HashMap<>()); + assertFalse("Without the rotated key configured, its token must not verify", ta.verifyToken(token)); + } + /** * Test getSigningCertKid() function * @throws Exception diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java index 419d077c67..3cb0b27381 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java @@ -162,6 +162,42 @@ public void testIsExpired_Revoked() throws Exception { tss.isExpired(token); } + @Test + public void testConsumeToken_SingleUse() throws Exception { + // Single-use semantics for auth codes: the first consume wins, a second consume of the same + // id loses (the token is already gone), and the token state is actually removed. + final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60)); + final TokenStateService tss = createTokenStateService(); + final String tokenId = TokenUtils.getTokenId(token); + + addToken(tss, token, System.currentTimeMillis()); + + assertTrue("First consume should win.", tss.consumeToken(tokenId)); + assertFalse("Second consume of the same token must lose.", tss.consumeToken(tokenId)); + } + + @Test(expected = UnknownTokenException.class) + public void testConsumeToken_RemovesState() throws Exception { + final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60)); + final TokenStateService tss = createTokenStateService(); + final String tokenId = TokenUtils.getTokenId(token); + + addToken(tss, token, System.currentTimeMillis()); + assertTrue(tss.consumeToken(tokenId)); + + // The token must no longer be known after being consumed. + tss.getTokenExpiration(tokenId); + } + + @Test + public void testConsumeToken_UnknownToken() throws Exception { + // An id that was never stored cannot be "won" - consume reports false rather than throwing. + final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60)); + final TokenStateService tss = createTokenStateService(); + + assertFalse(tss.consumeToken(TokenUtils.getTokenId(token))); + } + @Test public void testRenewal() throws Exception { final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/topology/DefaultTopologyServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/topology/DefaultTopologyServiceTest.java index bf7cf976c0..e428d1e84f 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/topology/DefaultTopologyServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/topology/DefaultTopologyServiceTest.java @@ -595,6 +595,45 @@ public void testConfigurationCRUDAPI() throws Exception { } } + @Test + public void testDeployRejectsPathTraversal() throws Exception { + File dir = createDir(); + File topologyDir = new File(dir, "topologies"); + topologyDir.mkdirs(); + + File descriptorsDir = new File(dir, "descriptors"); + descriptorsDir.mkdirs(); + + File sharedProvidersDir = new File(dir, "shared-providers"); + sharedProvidersDir.mkdirs(); + + try { + TopologyService ts = new DefaultTopologyService(); + Map c = new HashMap<>(); + + GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(config.getReadOnlyOverrideTopologyNames()).andReturn(Collections.emptyList()).anyTimes(); + EasyMock.expect(config.getGatewayTopologyDir()).andReturn(topologyDir.getAbsolutePath()).anyTimes(); + EasyMock.expect(config.getGatewayConfDir()).andReturn(descriptorsDir.getParentFile().getAbsolutePath()).anyTimes(); + EasyMock.replay(config); + + ts.init(config, c); + + final String traversalName = "../../evil.json"; + final File escapeTarget = new File(dir.getParentFile(), "evil.json"); + assertFalse("precondition: escape target must not pre-exist", escapeTarget.exists()); + + assertFalse("deployProviderConfiguration must reject a path-traversal name", + ts.deployProviderConfiguration(traversalName, "malicious")); + assertFalse("deployDescriptor must reject a path-traversal name", + ts.deployDescriptor(traversalName, "malicious")); + + assertFalse("no file may be written outside the managed directory", escapeTarget.exists()); + } finally { + FileUtils.deleteQuietly(dir); + } + } + @Test public void testProviderParamsOrderIsPreserved() { diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java index 47331b8071..dab6288d9e 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/GatewayWebsocketHandlerTest.java @@ -27,6 +27,9 @@ import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.provider.federation.jwt.JWTMessages; import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.webshell.WebshellWebSocketAdapter; import org.easymock.EasyMock; import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; @@ -44,8 +47,10 @@ import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; +import java.security.KeyStore; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -168,6 +173,100 @@ public void testDisabledWebShell() throws Exception{ gatewayWebsocketHandler.createWebSocket(req,resp); } + @Test + public void testConfigureClientIdentityTwoWaySslGatewayIdentity() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + + KeyStore identity = KeyStore.getInstance(KeyStore.getDefaultType()); + identity.load(null, null); + char[] passphrase = "gateway-secret".toCharArray(); + + KeystoreService keystoreService = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(keystoreService.getKeystoreForGateway()).andReturn(identity).anyTimes(); + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.expect(aliasService.getGatewayIdentityPassphrase()).andReturn(passphrase).anyTimes(); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(services.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).anyTimes(); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.replay(gatewayConfig, keystoreService, aliasService, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertSame(identity, props.get(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertSame(passphrase, props.get(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + + @Test + public void testConfigureClientIdentityTwoWaySslSingleEku() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + + KeyStore clientIdentity = KeyStore.getInstance(KeyStore.getDefaultType()); + clientIdentity.load(null, null); + char[] passphrase = "client-secret".toCharArray(); + + KeystoreService keystoreService = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientIdentity).anyTimes(); + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.expect(aliasService.getHttpClientKeyPassphrase()).andReturn(passphrase).anyTimes(); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(services.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).anyTimes(); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.replay(gatewayConfig, keystoreService, aliasService, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertSame(clientIdentity, props.get(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertSame(passphrase, props.get(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + + @Test + public void testConfigureClientIdentityDisabledWhenNotTwoWaySsl() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).anyTimes(); + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.replay(gatewayConfig, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + + @Test + public void testConfigureClientIdentityTwoWaySslNullKeystoreContributesNothing() throws Exception { + GatewayConfig gatewayConfig = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + + KeystoreService keystoreService = EasyMock.createNiceMock(KeystoreService.class); + EasyMock.expect(keystoreService.getKeystoreForGateway()).andReturn(null).anyTimes(); + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(services.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).anyTimes(); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.replay(gatewayConfig, keystoreService, aliasService, services); + + GatewayWebsocketHandler handler = new GatewayWebsocketHandler(gatewayConfig, services); + Map props = new HashMap<>(); + handler.configureClientIdentity(props); + + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY)); + Assert.assertFalse(props.containsKey(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY)); + } + private ServletUpgradeRequest createServletUpgradeRequest(String url) throws Exception { HttpServletRequest mockRequest = new org.apache.knox.test.mock.MockHttpServletRequest() { @Override diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java new file mode 100644 index 0000000000..7fb659fd3c --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/websockets/ProxyWebSocketAdapterTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * 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.websockets; + +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.Assert; +import org.junit.Test; + +import javax.websocket.ClientEndpointConfig; +import java.security.KeyStore; + +public class ProxyWebSocketAdapterTest { + + private static KeyStore emptyKeyStore() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + return keyStore; + } + + @Test + public void testConfigureSslAppliesKeystoreAndTruststore() throws Exception { + KeyStore identity = emptyKeyStore(); + KeyStore truststore = emptyKeyStore(); + ClientEndpointConfig clientConfig = ClientEndpointConfig.Builder.create().build(); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY, truststore); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY, identity); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.KEYSTORE_KEY_PASSPHRASE_USER_PROPERTY, "secret".toCharArray()); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + ProxyWebSocketAdapter.configureSsl(sslContextFactory, clientConfig); + + Assert.assertSame(identity, sslContextFactory.getKeyStore()); + Assert.assertSame(truststore, sslContextFactory.getTrustStore()); + } + + @Test + public void testConfigureSslNoKeystoreWhenAbsent() throws Exception { + KeyStore truststore = emptyKeyStore(); + ClientEndpointConfig clientConfig = ClientEndpointConfig.Builder.create().build(); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY, truststore); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + ProxyWebSocketAdapter.configureSsl(sslContextFactory, clientConfig); + + Assert.assertNull(sslContextFactory.getKeyStore()); + Assert.assertSame(truststore, sslContextFactory.getTrustStore()); + } + + @Test + public void testConfigureSslKeystorePresentNullPassphrase() throws Exception { + KeyStore identity = emptyKeyStore(); + KeyStore truststore = emptyKeyStore(); + ClientEndpointConfig clientConfig = ClientEndpointConfig.Builder.create().build(); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.TRUSTSTORE_USER_PROPERTY, truststore); + clientConfig.getUserProperties().put(GatewayWebsocketHandler.KEYSTORE_USER_PROPERTY, identity); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + ProxyWebSocketAdapter.configureSsl(sslContextFactory, clientConfig); + + Assert.assertSame(identity, sslContextFactory.getKeyStore()); + Assert.assertSame(truststore, sslContextFactory.getTrustStore()); + } +} diff --git a/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif b/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif index 91fb4136c9..456105f986 100644 --- a/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif +++ b/gateway-server/src/test/resources/ldap-proxy-backend-test.ldif @@ -58,6 +58,12 @@ objectclass:groupOfNames cn: group2 member: uid=ldaptest1,ou=people,dc=hadoop,dc=apache,dc=org +dn: cn=group3,ou=groups,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:groupOfNames +cn: group3 +member: uid=ldaptest1,ou=people,dc=hadoop,dc=apache,dc=org + dn: cn=nameddifferently,ou=groups,dc=hadoop,dc=apache,dc=org objectclass:top objectclass:groupOfNames @@ -89,6 +95,19 @@ sAMAccountName: TestSam2 userPassword: 12345 mail: ldaptest2@example.com description: Test user ldaptest2 + +dn: uid=ldapmemberof,ou=people,dc=hadoop,dc=apache,dc=org +objectclass:top +objectclass:person +objectclass:organizationalPerson +objectclass:inetOrgPerson +cn: TestMemberOf +sn: Ldap +uid: ldapmemberof +sAMAccountName: TestMemberOf +userPassword: 12345 +mail: ldapmemberof@example.com +description: Test user ldapmemberof memberOf: cn=groupMemberOf1,ou=groups,dc=hadoop,dc=apache,dc=org memberOf: cn=groupMemberOf2,ou=groups,dc=hadoop,dc=apache,dc=org diff --git a/gateway-service-admin/src/main/java/org/apache/knox/gateway/service/admin/TopologiesResource.java b/gateway-service-admin/src/main/java/org/apache/knox/gateway/service/admin/TopologiesResource.java index 2c66faafd2..98279a5798 100644 --- a/gateway-service-admin/src/main/java/org/apache/knox/gateway/service/admin/TopologiesResource.java +++ b/gateway-service-admin/src/main/java/org/apache/knox/gateway/service/admin/TopologiesResource.java @@ -43,6 +43,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; @@ -55,8 +56,6 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; @@ -90,7 +89,7 @@ public class TopologiesResource { private static final String SINGLE_DESCRIPTOR_API_PATH = DESCRIPTORS_API_PATH + "/{name}"; private static final int RESOURCE_NAME_LENGTH_MAX = 100; - private static final Pattern RESOURCE_NAME_PATTERN = Pattern.compile("^[\\w-/.]+$"); + private static final Pattern RESOURCE_NAME_PATTERN = Pattern.compile("^[\\w.-]+$"); private static GatewaySpiMessages log = MessagesFactory.get(GatewaySpiMessages.class); @@ -171,12 +170,6 @@ public SimpleTopologyWrapper getTopologies() { public Topology uploadTopology(@PathParam("id") String id, Topology t) { Topology result = null; - try { - id = URLDecoder.decode(id, StandardCharsets.UTF_8.name()); - } catch (Exception e) { - // Ignore - } - if (!isValidResourceName(id)) { log.invalidResourceName(id); throw new BadRequestException("Invalid topology name: " + id); @@ -188,6 +181,17 @@ public Topology uploadTopology(@PathParam("id") String id, Topology t) { t.setName(id); TopologyService ts = gs.getService(ServiceType.TOPOLOGY_SERVICE); + GatewayConfig config = + (GatewayConfig) request.getServletContext().getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + if (config != null && + config.getReadOnlyOverrideTopologyNames().contains(FilenameUtils.getBaseName(id))) { + log.disallowedOverwritingReadOnlyTopology(id); + throw new WebApplicationException( + status(Response.Status.FORBIDDEN) + .entity("{ \"error\" : \"Cannot overwrite read-only topology: " + id + "\" }") + .build()); + } + // Check for existing topology with the same name, to see if it had been generated boolean existingGenerated = false; for (org.apache.knox.gateway.topology.Topology existingTopology : ts.getTopologies()) { @@ -338,12 +342,6 @@ public Response deleteSimpleDescriptor(@PathParam("name") String name) { public Response uploadProviderConfiguration(@PathParam("name") String name, @Context HttpHeaders headers, String content) { Response response = null; - try { - name = URLDecoder.decode(name, StandardCharsets.UTF_8.name()); - } catch (Exception e) { - // Ignore - } - if (!isValidResourceName(name)) { log.invalidResourceName(name); throw new BadRequestException("Invalid provider configuration name: " + name); @@ -393,12 +391,6 @@ public Response uploadSimpleDescriptor(@PathParam("name") String name, String content) { Response response = null; - try { - name = URLDecoder.decode(name, StandardCharsets.UTF_8.name()); - } catch (Exception e) { - // Ignore - } - if (!isValidResourceName(name)) { log.invalidResourceName(name); throw new BadRequestException("Invalid descriptor name: " + name); @@ -554,8 +546,9 @@ private File getExistingConfigFile(Collection existing, String candidateNa return result; } - private static boolean isValidResourceName(final String name) { + static boolean isValidResourceName(final String name) { return name != null && name.length() <= RESOURCE_NAME_LENGTH_MAX && + !name.contains("..") && RESOURCE_NAME_PATTERN.matcher(name).matches(); } diff --git a/gateway-service-admin/src/test/java/org/apache/knox/gateway/service/admin/TopologyResourceTest.java b/gateway-service-admin/src/test/java/org/apache/knox/gateway/service/admin/TopologyResourceTest.java index e49015e1f9..d0e225ed13 100644 --- a/gateway-service-admin/src/test/java/org/apache/knox/gateway/service/admin/TopologyResourceTest.java +++ b/gateway-service-admin/src/test/java/org/apache/knox/gateway/service/admin/TopologyResourceTest.java @@ -19,7 +19,18 @@ import org.apache.knox.gateway.topology.Topology; import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.topology.TopologyService; + +import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; import org.easymock.EasyMock; import org.junit.Test; @@ -27,6 +38,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.CoreMatchers.containsString; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class TopologyResourceTest { @@ -174,6 +188,28 @@ public void testTopologyURLMethods(){ } + @Test + public void testResourceNameValidation() { + assertTrue(TopologiesResource.isValidResourceName("foo")); + assertTrue(TopologiesResource.isValidResourceName("foo.json")); + assertTrue(TopologiesResource.isValidResourceName("my-provider_1")); + assertTrue(TopologiesResource.isValidResourceName("a.b.c")); + + assertFalse(TopologiesResource.isValidResourceName("../../etc/passwd")); + assertFalse(TopologiesResource.isValidResourceName("..")); + assertFalse(TopologiesResource.isValidResourceName("foo/bar")); + assertFalse(TopologiesResource.isValidResourceName("a/../../b")); + assertFalse(TopologiesResource.isValidResourceName("foo..bar")); + + assertFalse(TopologiesResource.isValidResourceName("%2f")); + assertFalse(TopologiesResource.isValidResourceName("%252f")); + + assertFalse(TopologiesResource.isValidResourceName(null)); + assertFalse(TopologiesResource.isValidResourceName("")); + assertFalse(TopologiesResource.isValidResourceName("a".repeat(101))); + assertTrue(TopologiesResource.isValidResourceName("a".repeat(100))); + } + private void setDefaultExpectations(HttpServletRequest request){ EasyMock.expect( request.getPathInfo() ).andReturn( pathInfo ).anyTimes(); EasyMock.expect( request.getContextPath() ).andReturn( reqContext ).anyTimes(); @@ -186,4 +222,76 @@ private void setMockRequestHeader(HttpServletRequest request, String header, Str EasyMock.expect( request.getHeader( header ) ).andReturn( expected ).anyTimes(); } + @Test + public void testUploadTopologyRefusesReadOnlyOverride() throws Exception { + TopologyService ts = EasyMock.createMock(TopologyService.class); + + GatewayServices gs = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gs.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(ts).anyTimes(); + + GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(config.getReadOnlyOverrideTopologyNames()) + .andReturn(List.of("manager")).anyTimes(); + + HttpServletRequest request = mockRequest(gs, config); + + EasyMock.replay(ts, gs, config, request); + + TopologiesResource res = new TopologiesResource(); + setRequestField(res, request); + + try { + res.uploadTopology("manager", new org.apache.knox.gateway.service.admin.beans.Topology()); + fail("Expected WebApplicationException for read-only override topology"); + } catch (WebApplicationException e) { + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), e.getResponse().getStatus()); + } + + EasyMock.verify(ts); + } + + @Test + public void testUploadTopologyAllowedWhenNotReadOnly() throws Exception { + TopologyService ts = EasyMock.createMock(TopologyService.class); + EasyMock.expect(ts.getTopologies()).andReturn(Collections.emptyList()).anyTimes(); + ts.deployTopology(EasyMock.anyObject(Topology.class)); + EasyMock.expectLastCall().once(); + + GatewayServices gs = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gs.getService(ServiceType.TOPOLOGY_SERVICE)).andReturn(ts).anyTimes(); + + GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(config.getReadOnlyOverrideTopologyNames()) + .andReturn(Collections.emptyList()).anyTimes(); + + HttpServletRequest request = mockRequest(gs, config); + + EasyMock.replay(ts, gs, config, request); + + TopologiesResource res = new TopologiesResource(); + setRequestField(res, request); + + res.uploadTopology("sandbox", new org.apache.knox.gateway.service.admin.beans.Topology()); + + EasyMock.verify(ts); + } + + private HttpServletRequest mockRequest(GatewayServices gs, GatewayConfig config) { + ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)) + .andReturn(gs).anyTimes(); + EasyMock.expect(context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)) + .andReturn(config).anyTimes(); + HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getServletContext()).andReturn(context).anyTimes(); + EasyMock.replay(context); + return request; + } + + private void setRequestField(TopologiesResource res, HttpServletRequest request) throws Exception { + Field f = TopologiesResource.class.getDeclaredField("request"); + f.setAccessible(true); + f.set(res, request); + } + } diff --git a/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java b/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java index 8ed3aa660e..358b68b0f3 100644 --- a/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java +++ b/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java @@ -43,6 +43,7 @@ public abstract class AbstractAuthResource { public static final String AUTH_ACTOR_ID_HEADER_NAME = "preauth.auth.header.actor.id.name"; + public static final String AUTH_ACTOR_GROUPS_HEADER_NAME = "preauth.auth.header.actor.groups"; public static final String AUTH_ACTOR_GROUPS_HEADER_PREFIX = "preauth.auth.header.actor.groups.prefix"; public static final String GROUP_HEADER_LENGTH_LIMIT = "preauth.auth.header.groups.length.limit"; public static final String GROUP_HEADER_SIZE_LIMIT = "preauth.auth.header.groups.size.limit"; @@ -60,6 +61,7 @@ public abstract class AbstractAuthResource { private static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d"; protected String authHeaderActorIDName; + protected String authHeaderActorGroupsName; protected String authHeaderActorGroupsPrefix; private int groupHeaderLengthLimit; private int groupHeaderSizeLimit; @@ -69,6 +71,7 @@ public abstract class AbstractAuthResource { protected void initialize() { authHeaderActorIDName = getInitParameter(AUTH_ACTOR_ID_HEADER_NAME, DEFAULT_AUTH_ACTOR_ID_HEADER_NAME); + authHeaderActorGroupsName = getInitParameter(AUTH_ACTOR_GROUPS_HEADER_NAME, null); authHeaderActorGroupsPrefix = getInitParameter(AUTH_ACTOR_GROUPS_HEADER_PREFIX, DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX); groupHeaderLengthLimit = Integer.parseInt(getInitParameter(GROUP_HEADER_LENGTH_LIMIT, DEFAULT_GROUP_HEADER_LENGTH_LIMIT)); groupHeaderSizeLimit = Integer.parseInt(getInitParameter(GROUP_HEADER_SIZE_LIMIT, DEFAULT_GROUP_HEADER_SIZE_LIMIT)); @@ -114,13 +117,23 @@ public Response doGetImpl() { final boolean useRoles = !roles.isEmpty(); final List groupStrings = GroupUtils.getGroupStrings(useRoles ? roles : matchingGroupNames, groupHeaderLengthLimit, groupHeaderSizeLimit); for (int i = 0; i < groupStrings.size(); i++) { - final String headerName = useRoles || rolesLookupExecuted() ? authHeaderActorGroupsPrefix : String.format(Locale.ROOT, ACTOR_GROUPS_HEADER_FORMAT, authHeaderActorGroupsPrefix, i + 1); - getResponse().addHeader(headerName, groupStrings.get(i)); + getResponse().addHeader(createGroupsHeaderName(useRoles, i), groupStrings.get(i)); } } return ok().build(); } + private String createGroupsHeaderName(boolean useRoles, int index) { + if (authHeaderActorGroupsName != null) { + // explicit groups header takes precedence over the prefix and is used directly, without an index suffix + return authHeaderActorGroupsName; + } else if (useRoles || rolesLookupExecuted()) { + return authHeaderActorGroupsPrefix; + } else { + return String.format(Locale.ROOT, ACTOR_GROUPS_HEADER_FORMAT, authHeaderActorGroupsPrefix, index + 1); + } + } + private Collection lookupRoles(String userName, Collection groups) { Collection roles = null; try { diff --git a/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java b/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java index 5e1496c832..136b0ed39a 100644 --- a/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java +++ b/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java @@ -169,6 +169,33 @@ public void testPopulatingCustomGroupsHeader() throws Exception { EasyMock.verify(response); } + @Test + public void testExplicitGroupsHeaderTakesPrecedenceOverPrefix() throws Exception { + final String explicitGroupsHeader = "X-Knox-Actor-Groups"; + subject.getPrincipals().add(new GroupPrincipal("group1")); + + context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getInitParameter(PreAuthResource.AUTH_ACTOR_GROUPS_HEADER_NAME)).andReturn(explicitGroupsHeader).anyTimes(); + // a prefix is configured as well, to prove the explicit header wins and no index suffix is appended + EasyMock.expect(context.getInitParameter(PreAuthResource.AUTH_ACTOR_GROUPS_HEADER_PREFIX)).andReturn("X-Knox-Prefixed-Groups").anyTimes(); + request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getAttribute(AbstractIdentityAssertionBase.ROLES_LOOKUP_EXECUTED)).andReturn(false).anyTimes(); + response = EasyMock.createNiceMock(HttpServletResponse.class); + response.setHeader(PreAuthResource.DEFAULT_AUTH_ACTOR_ID_HEADER_NAME, USER_NAME); + EasyMock.expectLastCall(); + // the explicit header name is used directly, without an index suffix + response.addHeader(EasyMock.eq(explicitGroupsHeader), EasyMock.anyString()); + EasyMock.expectLastCall().times(1); + EasyMock.replay(context, request, response); + + final PreAuthResource preAuthResource = new PreAuthResource(); + preAuthResource.context = context; + preAuthResource.response = response; + preAuthResource.request = request; + executeResourceWithSubject(preAuthResource); + EasyMock.verify(response); + } + @Test public void testPopulatingGroupsWithRoles() throws Exception { final String rolesHeader = "X-Knox-Roles"; diff --git a/gateway-service-knoxidf/pom.xml b/gateway-service-knoxidf/pom.xml new file mode 100644 index 0000000000..3c5fbce588 --- /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-annotations + + + 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..6b1b946cb2 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthConsentServlet.java @@ -0,0 +1,133 @@ +/* + * 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.text.StringEscapeUtils; + +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("
"); + } + + // Accept/deny POST directly to the JAX-RS consent endpoints (which require POST) via each + // button's formaction, so accepting consent is never triggerable by a passive GET (prefetch, + // history re-nav, a leaked consent-state URL). The base path is derived from the servlet + // context and a compile-time constant, so it needs no escaping. + final String consentBasePath = request.getServletContext().getContextPath() + "/" + AuthorizeResource.RESOURCE_PATH; + out.println("
"); + // Render state in a double-quoted attribute: getRequestParamSafe escapes via escapeHtml4, + // which encodes '"' (") but NOT a single quote, so a single-quoted attribute here + // would let an attacker-supplied state break out of the attribute and inject markup. + out.printf(Locale.US, "%n", state); + out.println("
"); + out.printf(Locale.US, "%n", consentBasePath); + out.printf(Locale.US, "%n", consentBasePath); + 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: + // Unknown scopes are echoed into the HTML consent page. Escape them so an + // attacker-influenced scope value cannot inject markup (defense in depth). + return StringEscapeUtils.escapeHtml4(scope); + } + } + +} diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java new file mode 100644 index 0000000000..ebe56c144e --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java @@ -0,0 +1,844 @@ +/* + * 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.JOSEObjectType; +import com.nimbusds.jose.KeyLengthException; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.SecurityContext; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.http.NameValuePair; +import org.apache.http.client.config.RequestConfig; +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.config.GatewayConfig; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.security.SubjectUtils; +import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceLifecycleException; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.http.ssl.SSLContexts; +import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity; +import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService; +import org.apache.knox.gateway.services.security.AliasService; +import org.apache.knox.gateway.services.security.AliasServiceException; +import org.apache.knox.gateway.services.security.KeystoreService; +import org.apache.knox.gateway.services.security.token.JWTokenAuthority; +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenMetadataType; +import org.apache.knox.gateway.services.security.token.TokenServiceException; +import org.apache.knox.gateway.services.security.token.UnknownTokenException; +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.apache.knox.gateway.services.security.token.impl.JWTToken; +import org.apache.knox.gateway.util.JsonUtils; +import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadata; +import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadataStore; +import org.apache.knox.gateway.util.knoxidf.FederatedNonceStore; +import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration; +import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore; + +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 javax.net.ssl.SSLContext; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +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_RESOURCE_PATH; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE; +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.PKCE_METHOD_S256; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URIS; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.RESPONSE_TYPE; +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_RESOURCE_PATH + "/authorize"; + // RFC 4122 "URL" namespace UUID. Used as the fixed namespace for deriving a STABLE Knox + // subject (UUIDv5) from a federated identity's issuer+subject (see deriveKnoxSubject), so the + // same upstream user always maps to the same Knox 'sub' across logins and gateway restarts. + // Must not change once federated identities are persisted -- it would rewrite every existing + // federated user's subject. + private static final UUID KNOX_NAMESPACE = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8"); + private static final NameBasedGenerator UUID_V5 = Generators.nameBasedGenerator(KNOX_NAMESPACE); + public static final Set 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); + private final FederatedNonceStore federatedNonceStore = FederatedNonceStore.getInstance(120000L); + + @Context + private HttpServletRequest request; + + @Context + private ServletContext servletContext; + + private FederatedIdentityService federatedIdentityService; + private boolean autoConsentEnabled; + + @Override + public String getPrefix() { + return "knoxidf."; + } + + @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); + // Skipping user consent is a server-side deployment decision, never a client-supplied + // request parameter: a client must not be able to bypass the consent screen by sending + // auto_consent=true. + this.autoConsentEnabled = "true".equalsIgnoreCase(servletContext.getInitParameter("knoxidf.auto.consent.enabled")); + } + + @Override + @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(); + // DEFAULT_SCOPES is an ImmutableSet; copy it into a mutable set so downstream mutation is safe. + final Set requestedScopes = StringUtils.isBlank(scope) ? new HashSet<>(DEFAULT_SCOPES) : new HashSet<>(Arrays.asList(scope.split("\\s+"))); + final AuthorizeRequestMetadata authorizeRequestMetadata = new AuthorizeRequestMetadata(clientId, subject, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod); + final Response verificationErrorResponse = verifyParams(authorizeRequestMetadata); + if (verificationErrorResponse != null) { + // The authorization request was rejected (unknown client_id, bad redirect_uri, disallowed + // scope, or unsupported PKCE method). Record the rejection with the masked client_id. + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=authorize subject=" + KnoxIDFAudit.subjectLabel(subject) + + " reason=request_rejected"); + return verificationErrorResponse; + } + + if (!hasConsent(authorizeRequestMetadata)) { + if (autoConsentEnabled) { + markConsentAccepted(authorizeRequestMetadata); + } else { + final String consentAuthState = UUID.randomUUID().toString(); + authorizeRequestMetadataStore.put(consentAuthState, authorizeRequestMetadata); + final String baseUri = servletContext.getContextPath() + "/authConsent"; + // Every value placed into the consent redirect's query string must be percent-encoded; + // a client_id containing '&', '=' or '#' would otherwise split or corrupt the URL. + final String clientIdParam = URLEncoder.encode(clientId, StandardCharsets.UTF_8); + final String scopeParam = URLEncoder.encode(authorizeRequestMetadata.getJoinedRequestedScopes(), StandardCharsets.UTF_8); + final String redirect = String.format(Locale.US, "%s?client_id=%s&state=%s&scope=%s", baseUri, clientIdParam, 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 = consentMetadataKey(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(consentMetadataKey(authorizeRequestMetadata.getSubject()), authorizeRequestMetadata.getJoinedRequestedScopes()); + tokenStateService.addMetadata(authorizeRequestMetadata.getClientId(), consentAcceptedMetadata); + } + + /** + * Derives the metadata key under which a subject's granted consent scopes are stored. Consent is + * persisted in {@code KNOX_TOKEN_METADATA.md_name}, which is {@code VARCHAR(32)}; the previous + * {@code "consentAccepted_" + subject} key overflowed that for realistic subjects (federated + * UUID subjects, long usernames), silently truncating or failing the write on strict dialects. + * This derives a fixed-width key {@code "consent_" + first-20-hex-chars(SHA-256(subject))} = 28 + * chars, comfortably within the column. ~80 bits of hash is collision-safe for any realistic + * user population, and the derivation is uniform for plain usernames and UUID subjects alike. + * {@link #hasConsent} and {@link #markConsentAccepted} both route through here so read and write + * always agree on the key. + */ + static String consentMetadataKey(final String subject) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest((subject == null ? "" : subject).getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder("consent_"); + for (int i = 0; i < 10; i++) { // 10 bytes -> 20 hex chars + hex.append(String.format(Locale.US, "%02x", hash[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is a required algorithm on every JRE; its absence is unrecoverable. + throw new IllegalStateException("SHA-256 is required but unavailable", e); + } + } + + private Response getAuthCodeFromKnox(final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair federatedTokens) { + final String clientId = authorizeRequestMetadata.getClientId(); + final String subject = KnoxIDFAudit.subjectLabel(authorizeRequestMetadata.getSubject()); + final Response tokenResponse = getAuthenticationToken(); + if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) { + final Map tokenResponseMap = JsonUtils.getMapFromJsonString(tokenResponse.getEntity().toString()); + final String tokenId = tokenResponseMap.get(TOKEN_ID); + decorateAuthCodeToken(tokenId, authorizeRequestMetadata, federatedTokens); + // An authorization code was issued to the client for this subject. The code is masked; + // it is a single-use credential and its full value must never appear in the audit log. + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + ActionOutcome.SUCCESS, "event=authorize subject=" + subject + " code=" + KnoxIDFAudit.mask(tokenId) + + (federatedTokens == null ? "" : " federated=true") + " reason=code_issued"); + return redirectToAuthSuccess(authorizeRequestMetadata, tokenId); + } + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=authorize subject=" + subject + " reason=code_issuance_failed"); + return tokenResponse; + } + + private Response redirectToAuthSuccess(final AuthorizeRequestMetadata authorizeRequestMetadata, final String code) { + try { + final String redirectLocation = buildSuccessRedirect( + authorizeRequestMetadata.getRedirectUri(), code, authorizeRequestMetadata.getState()); + return Response.seeOther(URI.create(redirectLocation)).build(); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); //This should never happen with UTF-8 + } + } + + /** + * Appends the {@code code} and {@code state} authorization-response params to the client's + * registered redirect_uri. Uses {@code &} as the separator when the redirect_uri already carries + * a query string and {@code ?} otherwise, so a registered URI such as + * {@code https://app.example/cb?ui=1} produces {@code ...?ui=1&code=...&state=...} rather than a + * malformed second {@code ?} that the client would fail to parse. Package-private for testing. + */ + static String buildSuccessRedirect(final String redirectUri, final String code, final String state) + throws UnsupportedEncodingException { + final String separator = redirectUri.contains("?") ? "&" : "?"; + return redirectUri + + separator + "code=" + URLEncoder.encode(code, UTF_8) + + "&state=" + URLEncoder.encode(state, UTF_8); + } + + @GET + @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); + // Audit the federated-OP login callback exactly once. The resource is the federated OP name + // (once known); the reason distinguishes the failure modes. The federated auth code and the + // OP tokens are never logged. + String opName = KnoxIDFAudit.UNKNOWN; + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; + try { + if (StringUtils.isBlank(state) || StringUtils.isBlank(federatedAuthCode)) { + detail = "reason=missing_state_or_code"; + return error("invalid_request", "Missing state or code"); + } + final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state); + if (authorizeRequestMetadata == null) { + detail = "reason=unknown_state"; + return error("invalid_request", "Unknown or expired state"); + } + final Set opConfigs = federatedOpConfigurationStore.get(state); + final FederatedOpConfiguration federatedOpConfiguration = opConfigs == null ? null : opConfigs.stream().findFirst().orElse(null); + if (federatedOpConfiguration == null) { + detail = "reason=no_op_configuration"; + return error("invalid_request", "No federated OP configuration available for the request"); + } + opName = federatedOpConfiguration.getName(); + // The federated callback state is single-use: invalidate it in both stores now that it has + // been validated and captured, so a replayed callback with the same state is rejected. The + // nonce Knox sent to the OP was stashed under the same key (the login-session id == state); + // retrieve and invalidate it too so it cannot be reused. + authorizeRequestMetadataStore.remove(state); + federatedOpConfigurationStore.remove(state); + final String expectedNonce = federatedNonceStore.get(state); + federatedNonceStore.remove(state); + final Pair federatedTokens; + try { + federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration); + } catch (ClientSecretResolutionException e) { + // A configured client-secret alias could not be resolved. This is a server-side + // misconfiguration, not a client error, and we deliberately never made the OP call. + detail = "reason=client_secret_unresolved"; + return error("server_error", e.getMessage()); + } catch (FederatedTokenExchangeException e) { + // The OP's token endpoint returned a non-200. Audit the real cause (the OP status, + // without its response body) and return a generic server_error instead of letting the + // exception escape as a 500 that could leak the OP's error body. + detail = "reason=federated_token_exchange_failed op_status=" + e.getOpStatus(); + return error("server_error", "Federated authentication failed"); + } + if (StringUtils.isBlank(federatedTokens.getLeft())) { + detail = "reason=no_id_token"; + return error("invalid_request", "Federated OP did not return an id_token"); + } + final JWT federatedIdToken = new JWTToken(federatedTokens.getLeft()); + // Verify the OP's id_token (signature/issuer/audience/expiry) before trusting any claim in it. + final Response validationError = validateFederatedIdToken(federatedIdToken, federatedOpConfiguration); + if (validationError != null) { + detail = "reason=id_token_validation_failed"; + return validationError; + } + // Bind the (now signature-verified) id_token to this authorization request (OIDC Core 3.1.2.1): + // its nonce claim must equal the nonce Knox generated and sent to the OP for this login session. + // This is checked only after the token's authenticity is established, so a forged token cannot + // supply its own matching nonce. A missing expected nonce (e.g. expired/replayed state) or a + // mismatch fails the flow. + final Response nonceError = verifyFederatedNonce(expectedNonce, federatedIdToken); + if (nonceError != null) { + detail = "reason=nonce_mismatch"; + return nonceError; + } + final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName()); + outcome = ActionOutcome.SUCCESS; + detail = "reason=federated_identity_resolved subject=" + KnoxIDFAudit.subjectLabel(federatedIdentity.getId()); + return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight())); + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, opName, ResourceType.TRUSTED_ISSUER, outcome, + "event=federated_callback op=" + opName + " " + detail); + } + } + + // POST, not GET: accepting consent persists a consent record and issues an authorization code, so + // it must not be triggerable by passive browser navigation (img/link prefetch, history re-nav) or + // by a leaked consent-state URL. Requiring a form POST puts it under the browser's same-origin/CSRF + // model. + @POST + @Path("/consentAccepted") + public Response consentAccepted() throws Exception { + final String state = request.getParameter(STATE); + final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state); + if (authorizeRequestMetadata == null) { + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.UNKNOWN, ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=consent reason=invalid_or_expired_state"); + return error("invalid_request", "Invalid state"); + } + + // Bind consent to the subject that initiated the authorization request. Without this, user B + // (authenticated) could replay user A's consent-state URL and have consent recorded for A while + // an auth code is minted for B and sent to A's redirect_uri (cross-user consent / mis-routed + // code). The consent state is single-use, so consume it before rejecting a mismatch too. + final String currentSubject = SubjectUtils.getCurrentEffectivePrincipalName(); + if (currentSubject == null || !currentSubject.equals(authorizeRequestMetadata.getSubject())) { + authorizeRequestMetadataStore.remove(state); + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(authorizeRequestMetadata.getClientId()), + ResourceType.PRINCIPAL, ActionOutcome.FAILURE, "event=consent subject=" + + KnoxIDFAudit.subjectLabel(currentSubject) + " reason=subject_mismatch"); + return error("access_denied", "Consent subject mismatch"); + } + + // Single-use consent state: invalidate it so the accepted-consent redirect cannot be replayed. + authorizeRequestMetadataStore.remove(state); + markConsentAccepted(authorizeRequestMetadata); + // Consent was granted by the subject for this client; the ensuing authorize() call audits the + // resulting code issuance separately. + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(authorizeRequestMetadata.getClientId()), + ResourceType.PRINCIPAL, ActionOutcome.SUCCESS, "event=consent subject=" + + KnoxIDFAudit.subjectLabel(authorizeRequestMetadata.getSubject()) + " reason=consent_granted"); + return authorize(authorizeRequestMetadata.getResponseType(), + authorizeRequestMetadata.getClientId(), + authorizeRequestMetadata.getRedirectUri(), + authorizeRequestMetadata.getJoinedRequestedScopes(), + authorizeRequestMetadata.getState(), + authorizeRequestMetadata.getNonce(), + authorizeRequestMetadata.getCodeChallenge(), + authorizeRequestMetadata.getCodeChallengeMethod()); + } + + @POST + @Path("/consentDenied") + public Response consentDenied() throws Exception { + KnoxIDFAudit.audit(Action.AUTHORIZATION, + KnoxIDFAudit.subjectLabel(SubjectUtils.getCurrentEffectivePrincipalName()), ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=consent reason=consent_denied"); + return Response.status(Response.Status.FORBIDDEN).entity("Consent denied!").build(); + } + + 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()); + // Method is validated to be S256 in verifyParams; store it as-is (no 'plain' default). + authCodeTokenMap.put(CODE_CHALLENGE_METHOD, authorizeRequestMetadata.getCodeChallengeMethod()); + } + if (federatedTokens != null) { + // Persist only the pointer to the (separately stored) federated identity. The OP's + // access token (federatedTokens.getRight()) is deliberately NOT persisted: nothing reads + // it back, and storing an OP bearer secret in plaintext token metadata is a secret-at-rest + // exposure. If a future feature needs it, store it encrypted, not in the clear. + authCodeTokenMap.put(FEDERATED_IDENTITY_ID, federatedTokens.getLeft()); + } + 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"); + } + + // PKCE: only the S256 challenge method is supported. 'plain' (and an unspecified method, + // which OAuth would default to 'plain') offers no protection and is rejected. + if (StringUtils.isNotBlank(authorizeRequestMetadata.getCodeChallenge()) + && !PKCE_METHOD_S256.equals(authorizeRequestMetadata.getCodeChallengeMethod())) { + return error("invalid_request", "Unsupported code_challenge_method; only S256 is supported"); + } + + return null; + } + return basicVerificationResponse; + } + + // Package-private for testability (wildcard path-traversal matching is exercised by + // AuthorizeResourceRedirectUriMatchTest); not part of the public resource API. + boolean matchesRedirectUri(String requestedUri, Set registeredUris) { + final URI requested = parseUri(requestedUri); + if (requested == null) { + return false; + } + for (String registered : registeredUris) { + if (registered.endsWith("*")) { + // Wildcard is a path-prefix match, but the origin (scheme/host/port) must match + // exactly. Comparing parsed components prevents a bare startsWith from letting + // "https://good.example*" match "https://good.example.evil.com". + final URI base = parseUri(registered.substring(0, registered.length() - 1)); + if (base != null && sameOrigin(base, requested)) { + // Normalize the requested path before the prefix compare so a traversal segment + // cannot escape the registered prefix: a raw startsWith would let + // ".../callback/../admin" match ".../callback/*" and deliver the code to /admin. + // normalize() collapses "/callback/../admin" to "/admin", which no longer matches. + final String basePath = base.normalize().getPath() == null ? "" : base.normalize().getPath(); + final String reqPath = requested.normalize().getPath() == null ? "" : requested.normalize().getPath(); + if (reqPath.startsWith(basePath)) { + return true; + } + } + } else if (registered.equals(requestedUri)) { + return true; + } + } + return false; + } + + private static URI parseUri(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return new URI(value); + } catch (URISyntaxException e) { + return null; + } + } + + private static boolean sameOrigin(URI a, URI b) { + return a.getScheme() != null && a.getScheme().equalsIgnoreCase(b.getScheme()) + && a.getHost() != null && a.getHost().equalsIgnoreCase(b.getHost()) + && a.getPort() == b.getPort(); + } + + private Pair 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 { + // Do not embed the OP's response body in the exception: it can carry internal diagnostic + // codes and would otherwise surface in the audit log or a leaked 500 body. Carry only the + // HTTP status, which the caller audits and maps to a generic server_error. + throw new FederatedTokenExchangeException(federatedTokenExchangeResponse.getStatus()); + } + } + + /** + * Resolves the federated OP's client secret for the back-channel token request. An + * {@code AliasService} credential alias ({@code federated.op..clientSecret.alias}) is the + * preferred, secure source and takes precedence: when it resolves to a value, that value is + * used and the plaintext {@code clientSecret} topology param is never consulted. The plaintext + * param remains supported as a fallback only when no alias is configured, so existing + * deployments keep working. If an alias is configured but cannot be resolved we fail closed by + * throwing {@link ClientSecretResolutionException} rather than returning {@code null} (which the + * form encoder would have serialized to a literal {@code client_secret=null} sent to the OP) or + * silently leaking through to the plaintext param. The caller aborts the exchange before any + * HTTP request, so a misconfigured alias surfaces as a clear error instead of a bogus OP call. + */ + private String resolveClientSecret(final FederatedOpConfiguration opConfig) { + final String alias = opConfig.getClientSecretAlias(); + if (StringUtils.isBlank(alias)) { + return opConfig.getClientSecret(); + } + char[] secret = null; + try { + final AliasService aliasService = getGatewayServices().getService(ServiceType.ALIAS_SERVICE); + String clusterName = (String) servletContext.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE); + if (StringUtils.isBlank(clusterName)) { + clusterName = AliasService.NO_CLUSTER_NAME; + } + secret = aliasService.getPasswordFromAliasForCluster(clusterName, alias, false); + } catch (AliasServiceException e) { + // Fall through to the fail-closed check below; an alias was configured but its lookup failed. + secret = null; + } + return requireResolvedAliasSecret(alias, secret); + } + + /** + * Fail-closed guard for an explicitly configured client-secret alias: returns the resolved secret + * or throws {@link ClientSecretResolutionException} when it is absent/empty. Package-private and + * pure so the fail-closed decision is unit-testable without a live {@code AliasService}. + */ + static String requireResolvedAliasSecret(final String alias, final char[] secret) { + if (secret == null || secret.length == 0) { + throw new ClientSecretResolutionException( + "Federated OP client secret alias '" + alias + "' is configured but could not be resolved; " + + "refusing to contact the OP without the intended secret"); + } + return new String(secret); + } + + /** Signals that a configured client-secret alias could not be resolved; the exchange must abort. */ + static final class ClientSecretResolutionException extends RuntimeException { + ClientSecretResolutionException(final String message) { + super(message); + } + } + + /** + * Signals that the federated OP's token endpoint returned a non-200 response. Carries only the + * HTTP status (safe to audit); the OP's response body is deliberately not propagated so it cannot + * leak into the audit log or an error response. + */ + static final class FederatedTokenExchangeException extends RuntimeException { + private final int opStatus; + + FederatedTokenExchangeException(final int opStatus) { + super("Federated OP token endpoint returned HTTP " + opStatus); + this.opStatus = opStatus; + } + + int getOpStatus() { + return opStatus; + } + } + + 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, resolveClientSecret(opConfig))); + + try (CloseableHttpClient httpClient = createFederatedHttpClient()) { + HttpPost post = new HttpPost(opConfig.getTokenEndpoint()); + post.setHeader("Content-Type", "application/x-www-form-urlencoded"); + post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); + + 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(); + } + } + + /** + * Builds the HTTP client used for the back-channel token request to the federated OP. The + * OP's TLS certificate must be validated against the Gateway's configured truststore + * ({@code gateway.truststore.*}) rather than the process-wide default, so this mirrors the + * outbound-dispatch clients. When no Gateway truststore is configured we fall back to the + * default client (JVM default trust material), never to an unvalidated client. + */ + private CloseableHttpClient createFederatedHttpClient() throws Exception { + final KeystoreService keystoreService = getGatewayServices().getService(ServiceType.KEYSTORE_SERVICE); + final KeyStore trustStore = keystoreService.getTruststoreForHttpClient(); + // Bound every back-channel call to the OP: without connect/socket timeouts an unresponsive + // external OP token endpoint pins the calling request thread indefinitely, so enough hung + // federated logins can exhaust the gateway's request threads (availability DoS). Mirrors the + // timeout handling of the trusted-issuer discovery client (OIDCDiscoveryHelper). + final RequestConfig requestConfig = federatedRequestConfig(); + if (trustStore != null) { + final SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null).build(); + return HttpClients.custom().setSSLContext(sslContext).setDefaultRequestConfig(requestConfig).build(); + } + return HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); + } + + private RequestConfig federatedRequestConfig() { + final GatewayConfig config = servletContext == null + ? null + : (GatewayConfig) servletContext.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + final int connectTimeoutMs = config == null + ? GatewayConfig.KNOXIDF_FEDERATED_OP_CONNECT_TIMEOUT_MS_DEFAULT + : config.getKnoxIDFFederatedOpConnectTimeoutMs(); + final int readTimeoutMs = config == null + ? GatewayConfig.KNOXIDF_FEDERATED_OP_READ_TIMEOUT_MS_DEFAULT + : config.getKnoxIDFFederatedOpReadTimeoutMs(); + return RequestConfig.custom() + .setConnectTimeout(connectTimeoutMs) + .setConnectionRequestTimeout(connectTimeoutMs) + .setSocketTimeout(readTimeoutMs) + .build(); + } + + /** + * Verifies the federated OP's id_token before any claim in it is trusted: + *

    + *
  • signature against the OP's JWKS and {@code exp}/{@code nbf} (via {@link JWTokenAuthority});
  • + *
  • {@code iss} equals the configured OP issuer;
  • + *
  • {@code aud} contains our client_id registered at the OP.
  • + *
+ * Fails closed: if the OP is not configured with a JWKS endpoint, expected issuer and client_id, + * the token cannot be verified and the federated login is refused. + * + * @return an error {@link Response} if verification fails, or {@code null} if the token is valid. + */ + private Response validateFederatedIdToken(final JWT idToken, final FederatedOpConfiguration opConfig) { + final String jwksEndpoint = opConfig.getJwksEndpoint(); + final String expectedIssuer = opConfig.getIssuer(); + final String expectedAudience = opConfig.getClientId(); + if (StringUtils.isBlank(jwksEndpoint) || StringUtils.isBlank(expectedIssuer) || StringUtils.isBlank(expectedAudience)) { + return error("invalid_request", "Federated OP is missing jwks.endpoint/issuer/clientId configuration; cannot verify id_token"); + } + + try { + final JWTokenAuthority authority = getGatewayServices().getService(ServiceType.TOKEN_SERVICE); + // A non-null JWS type verifier is required: federated OP id_tokens carry a "typ" header + // (Keycloak and most OPs set typ=JWT), and the shared token authority rejects any typ'd + // token outright when no verifier is supplied. Accept "JWT" and a missing typ (typ is + // optional per RFC 7519) so we interoperate with the range of conformant OPs. + final JOSEObjectTypeVerifier typeVerifier = + new DefaultJOSEObjectTypeVerifier<>(new HashSet<>(Arrays.asList(JOSEObjectType.JWT, null))); + // Verifies the signature against the OP's JWKS and checks exp/nbf. + if (!authority.verifyToken(idToken, Collections.singleton(new URI(jwksEndpoint)), opConfig.getSignatureAlgorithm(), typeVerifier)) { + return error("invalid_request", "Federated id_token signature or expiry verification failed"); + } + } catch (URISyntaxException e) { + return error("invalid_request", "Invalid jwks.endpoint configured for federated OP"); + } catch (TokenServiceException e) { + return error("invalid_request", "Federated id_token verification error"); + } + + if (!expectedIssuer.equals(idToken.getIssuer())) { + return error("invalid_request", "Federated id_token issuer mismatch"); + } + + final String[] audiences = idToken.getAudienceClaims(); + if (audiences == null || !Arrays.asList(audiences).contains(expectedAudience)) { + return error("invalid_request", "Federated id_token audience mismatch"); + } + + return requireFederatedSubject(idToken); + } + + /** + * Verifies the OIDC {@code nonce} binding for a federated login (OIDC Core 3.1.2.1). The + * {@code expectedNonce} is the value Knox generated for this login session and sent to the OP; + * it must equal the {@code nonce} claim of the (already signature-verified) id_token. Callers + * must invoke this only after {@link #validateFederatedIdToken} succeeds so a forged token cannot + * assert its own nonce. + * + * @return an error {@link Response} on absence/mismatch, or {@code null} when the nonce matches. + */ + Response verifyFederatedNonce(final String expectedNonce, final JWT idToken) { + if (StringUtils.isBlank(expectedNonce)) { + return error("invalid_request", "Missing or expired federated login nonce"); + } + if (!expectedNonce.equals(idToken.getClaim(NONCE))) { + return error("invalid_request", "Federated id_token nonce mismatch"); + } + return null; + } + + /** + * Enforces that a verified federated id_token carries the {@code sub} claim, which OIDC Core 2 + * marks REQUIRED. Knox derives both the Knox subject and the federated-identity primary key from + * it, and the identity tables declare {@code external_subject NOT NULL}. A broken or hostile OP + * that omits {@code sub} would otherwise drive a NOT NULL insert failure -> HTTP 500 on every + * callback through that OP; reject it as a client/OP error instead. Call only after + * {@link #validateFederatedIdToken} has established the token's authenticity. + * + * @return an error {@link Response} when {@code sub} is absent/blank, or {@code null} otherwise. + */ + Response requireFederatedSubject(final JWT idToken) { + if (StringUtils.isBlank(idToken.getSubject())) { + return error("invalid_request", "Federated id_token is missing the required sub claim"); + } + return null; + } + + private FederatedIdentity resolveFederatedIdentity(final JWT jwt, String opName) { + final String issuer = jwt.getIssuer(); + final String subject = jwt.getSubject(); + 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 + ); + + // Return whatever the service persisted: on a concurrent first-login race this is the row the + // winning request inserted, not our local copy, so the downstream auth code is keyed to the id + // that actually exists in the identity table. + return federatedIdentityService.addFederatedIdentity(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..276ca8e8af --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResource.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.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_RESOURCE_PATH; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_EXCHANGE_TOPOLOGY_NAME; + +@Path(BASE_RESOURCE_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) { + // Literal substitution: the topology name is data, not a regex. replaceAll would treat + // any regex metacharacter in the topology name as a pattern. + tokenEndpoint = tokenEndpoint.replace(currentTopologyName, tokenExchangeTopologyName); + userInfoEndpoint = userInfoEndpoint.replace(currentTopologyName, tokenExchangeTopologyName); + } + config.put("token_endpoint", tokenEndpoint); + config.put("userinfo_endpoint", userInfoEndpoint); + // Dynamic client registration is served on the current topology (no token-exchange + // substitution); advertise it so clients can discover it per OIDC Dynamic Client Registration. + config.put("registration_endpoint", baseUrl + RegistrationResource.RESOURCE_PATH + "/register"); + config.put("jwks_uri", baseUrl + JwksResource.RESOURCE_PATH); + config.put("response_types_supported", new String[]{KnoxIDFConstants.CODE}); + // REQUIRED by OpenID Connect Discovery 1.0. Knox derives 'sub' as a deterministic UUIDv5 over + // a fixed namespace and the user identity -- the same for every client -- so the subject + // identifier type is "public" (not "pairwise"). + config.put("subject_types_supported", new String[]{"public"}); + // The token endpoint reads client credentials only from request parameters (no HTTP Basic): + // confidential clients send client_secret in the body (client_secret_post); public clients + // authenticate with PKCE and no secret ("none"). client_secret_basic is intentionally absent + // because it is not honored. + config.put("token_endpoint_auth_methods_supported", new String[]{"client_secret_post", "none"}); + // Explicitly false: Knox does not resolve an HTTPS-URL client_id to a fetched Client ID + // Metadata Document (OAuth CIMD draft, referenced by MCP). This is the spec default when the + // field is absent, but stating it tells MCP clients to use dynamic client registration + // (registration_endpoint) rather than a URL client_id. Flip to true only if CIMD is implemented. + config.put("client_id_metadata_document_supported", Boolean.FALSE); + config.put("grant_types_supported", new String[]{KnoxIDFConstants.AUTH_CODE, KnoxIDFConstants.REFRESH_TOKEN}); + config.put("scopes_supported", KnoxIDFConstants.DEFAULT_SCOPES); + config.put("id_token_signing_alg_values_supported", new String[]{"RS256"}); + // Advertise only S256: AuthorizeResource rejects any other code_challenge_method (including + // "plain"), so discovery must not claim "plain" support it does not honor. + config.put("code_challenge_methods_supported", new String[]{KnoxIDFConstants.PKCE_METHOD_S256}); + return Response.ok(JsonUtils.renderAsJsonString(config)).build(); + } + +} diff --git a/gateway-service-knoxidf/src/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..1dbca3c8eb --- /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_RESOURCE_PATH; + +@Path(JwksResource.RESOURCE_PATH) +@Produces(MediaType.APPLICATION_JSON) +public class JwksResource extends JWKSResource { + static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/jwks"; + + @GET + public Response getKeys() { + return getJwksResponse(); + } +} + diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java new file mode 100644 index 0000000000..62b49cdf29 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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.audit.api.AuditServiceFactory; +import org.apache.knox.gateway.audit.api.Auditor; +import org.apache.knox.gateway.audit.log4j.audit.AuditConstants; +import org.apache.knox.gateway.util.Tokens; + +/** + * Centralized audit emission for the KnoxIDF OAuth2/OIDC endpoints + * ({@link AuthorizeResource}, {@link TokenResource}, {@link RegistrationResource}, + * {@link UserInfoResource}). + *

+ * Every security-relevant decision on these endpoints — an authorization request accepted or + * rejected, consent shown/granted/denied, an authorization code issued, a code or refresh token + * redeemed or replayed, a client registered, a federated callback validated, user info served — is + * recorded through this class so the records share a single {@link Auditor} instance, a consistent + * action/outcome/resource shape and, crucially, a single masking rule: credentials and full tokens + * are NEVER written to the audit log. Token identifiers and JWTs are always passed through + * {@link #mask(String)} first, and {@code client_secret}/{@code code_verifier}/raw refresh tokens + * are never logged at all. + *

+ * The {@link Auditor} field mirrors {@link TrustedOidcIssuersResource}: it is package-private and + * non-final so a unit test can inject a capturing mock and assert the emitted record. + */ +final class KnoxIDFAudit { + + /** Placeholder used when a resource/subject identifier is absent or cannot be masked. */ + static final String UNKNOWN = "UNKNOWN"; + + /** Placeholder for an unauthenticated caller. */ + static final String ANONYMOUS = "ANONYMOUS"; + + // Non-final and package-private to allow test injection of a mock Auditor (see the sibling + // TrustedOidcIssuersResource, which uses the same idiom). + static Auditor auditor = AuditServiceFactory.getAuditService() + .getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME, + AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME); + + private KnoxIDFAudit() { + } + + /** + * Emits an audit record via the shared {@link Auditor}. The {@code resource} is used verbatim, so + * callers that pass a token identifier or JWT MUST first mask it with {@link #mask(String)}. A + * blank {@code resource} is normalized to {@link #UNKNOWN} because the underlying auditor rejects a + * null resource name. + */ + static void audit(final String action, final String resource, final String resourceType, + final String outcome, final String message) { + auditor.audit(action, StringUtils.isBlank(resource) ? UNKNOWN : resource, resourceType, outcome, message); + } + + /** + * Masks a token or token identifier for safe logging. A Knox token UUID is rendered via + * {@link Tokens#getTokenIDDisplayText(String)} and a JWT via {@link Tokens#getTokenDisplayText(String)}; + * both keep only a short prefix/suffix so the full secret never reaches the log. Returns + * {@link #UNKNOWN} for a blank or unmaskable value. This method never returns the raw input. + */ + static String mask(final String tokenOrId) { + if (StringUtils.isBlank(tokenOrId)) { + return UNKNOWN; + } + String display = Tokens.getTokenIDDisplayText(tokenOrId); + if (display == null) { + display = Tokens.getTokenDisplayText(tokenOrId); + } + return display == null ? UNKNOWN : display; + } + + /** Renders a subject/principal name for logging, mapping a blank/absent principal to {@link #ANONYMOUS}. */ + static String subjectLabel(final String subject) { + return StringUtils.isBlank(subject) ? ANONYMOUS : subject; + } +} diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/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/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/RegistrationResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java new file mode 100644 index 0000000000..1a18e4e868 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.security.SubjectUtils; +import org.apache.knox.gateway.service.knoxtoken.ClientCredentialsResource; +import org.apache.knox.gateway.services.ServiceLifecycleException; +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.ServletContext; +import javax.servlet.ServletException; +import javax.ws.rs.Consumes; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_REGISTRATION_ALLOWED_SCOPES; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_REGISTRATION_ANONYMOUS_ALLOWED; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_REGISTRATION_CUSTOM_LOOPBACK_HOSTS; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.DEFAULT_SCOPES; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.OIDC_STANDARD_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_RESOURCE_PATH + "/client"; + private static final String ANONYMOUS_PRINCIPAL = "anonymous"; + static final Set DEFAULT_LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1"); + + private List redirectUris; + private List allowedScopes; + boolean anonymousRegistrationAllowed; + Set loopbackHosts; + Set registerableScopes; + + @Context + private ServletContext servletContext; + + @Override + public String getPrefix() { + return "knoxidf."; + } + + @PostConstruct + @Override + public void init() throws ServletException, AliasServiceException, ServiceLifecycleException, KeyLengthException { + super.init(); + // Secure by default: unless the deployment explicitly opts in, an anonymous caller cannot + // register a client even when the topology wires this endpoint as 'anon'. + this.anonymousRegistrationAllowed = Boolean.parseBoolean(servletContext.getInitParameter(CLIENT_REGISTRATION_ANONYMOUS_ALLOWED)); + this.loopbackHosts = parseLoopbackHosts(servletContext.getInitParameter(CLIENT_REGISTRATION_CUSTOM_LOOPBACK_HOSTS)); + this.registerableScopes = parseRegisterableScopes(servletContext.getInitParameter(CLIENT_REGISTRATION_ALLOWED_SCOPES)); + } + + // Build the set of scopes a client is permitted to register: the operator-configured whitelist + // (comma-separated, trimmed, blanks dropped) or, when unset/blank, the OIDC-standard scope set. + // 'openid' is always registerable regardless of the configured value. + static Set parseRegisterableScopes(String configured) { + if (StringUtils.isBlank(configured)) { + return OIDC_STANDARD_SCOPES; + } + final Set scopes = new HashSet<>(); + for (String s : configured.split(",")) { + final String trimmed = s.trim(); + if (!trimmed.isEmpty()) { + scopes.add(trimmed); + } + } + scopes.add("openid"); + return scopes; + } + + // Build the loopback-host set: the hard-coded defaults plus any admin-configured extra hosts from the + // comma-separated config (trimmed, lowercased, blanks dropped). Null/blank config => defaults only. + static Set parseLoopbackHosts(String customLoopbackHosts) { + if (StringUtils.isBlank(customLoopbackHosts)) { + return DEFAULT_LOOPBACK_HOSTS; + } + final Set hosts = new HashSet<>(DEFAULT_LOOPBACK_HOSTS); + for (String h : customLoopbackHosts.split(",")) { + final String trimmed = h.trim(); + if (!trimmed.isEmpty()) { + hosts.add(trimmed.toLowerCase(Locale.ROOT)); + } + } + return hosts; + } + + @Override + @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) { + // Audit the outcome of every dynamic client-registration attempt exactly once, recording the + // caller principal and the reason for a rejection. No secret (the minted client_secret) is + // ever logged — only that a client was registered. + final String caller = KnoxIDFAudit.subjectLabel(SubjectUtils.getCurrentEffectivePrincipalName()); + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; + try { + if (anonymousRegistrationDenied()) { + detail = "reason=anonymous_denied"; + return error("access_denied", "Anonymous client registration is disabled. Set '" + + CLIENT_REGISTRATION_ANONYMOUS_ALLOWED + "' to true in the KNOXIDF service configuration to enable it."); + } + if (StringUtils.isBlank(redirectUris)) { + detail = "reason=missing_redirect_uris"; + return error("invalid_request", "redirect_uris must be provided"); + } + this.redirectUris = Arrays.asList(redirectUris.split(",")); + final Response redirectUriVerificationResponse = verifyRedirectUris(); + if (redirectUriVerificationResponse != null) { + detail = "reason=invalid_redirect_uris"; + return redirectUriVerificationResponse; + } + + if (StringUtils.isBlank(allowedScopes)) { + // No scopes requested: grant the built-in defaults, bounded by the server-side + // whitelist so a narrower operator policy is honored even for the default case. + this.allowedScopes = DEFAULT_SCOPES.stream() + .filter(registerableScopes::contains) + .collect(Collectors.toList()); + } else { + final List requestedScopes = Arrays.asList(allowedScopes.split(",")); + if (!requestedScopes.contains("openid")) { + detail = "reason=invalid_scope"; + return error("invalid_request", "allowed_scopes must include 'openid'"); + } + // Server-side whitelist: a client cannot self-assign a scope outside the registerable + // set, so it cannot mint tokens carrying a privileged scope a downstream service trusts. + final Optional disallowedScope = requestedScopes.stream() + .map(String::trim) + .filter(scope -> !scope.isEmpty()) + .filter(scope -> !registerableScopes.contains(scope)) + .findFirst(); + if (disallowedScope.isPresent()) { + detail = "reason=scope_not_registerable"; + return error("invalid_scope", "Scope '" + disallowedScope.get() + "' is not permitted for registration"); + } + this.allowedScopes = requestedScopes; + } + final Response response = super.doPost(); + outcome = ActionOutcome.SUCCESS; + detail = "reason=client_registered"; + return response; + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, caller, ResourceType.PRINCIPAL, outcome, + "event=client_registration " + detail); + } + } + + /** + * @return {@code true} when the request must be rejected because an anonymous caller is + * attempting to register a client while open registration has not been explicitly enabled. + */ + boolean anonymousRegistrationDenied() { + return !anonymousRegistrationAllowed && isAnonymousCaller(); + } + + private boolean isAnonymousCaller() { + return ANONYMOUS_PRINCIPAL.equalsIgnoreCase(SubjectUtils.getCurrentEffectivePrincipalName()); + } + + private Response verifyRedirectUris() { + return verifyRedirectUris(redirectUris, loopbackHosts); + } + + // Package-private and list-parameterized so the redirect-URI policy (https-only except loopback, + // no wildcard host, restricted path/query/fragment wildcards) is unit-testable in isolation. + static Response verifyRedirectUris(List redirectUris) { + return verifyRedirectUris(redirectUris, DEFAULT_LOOPBACK_HOSTS); + } + + // loopbackHosts: normalized (lowercase) hosts allowed to use a plain-HTTP redirect_uri. + static Response verifyRedirectUris(List redirectUris, Set loopbackHosts) { + 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); + } + + // Host check (no wildcard allowed) + if (uri.getHost() == null || uri.getHost().contains("*")) { + return error("invalid_request", "Wildcard not allowed in host: " + uriStr); + } + + // Scheme check: require HTTPS per RFC 8252, allowing plain HTTP only for loopback + // (localhost / 127.0.0.1 / ::1) native-app dev. Any other http:// redirect is rejected. + final String scheme = uri.getScheme(); + final boolean https = "https".equalsIgnoreCase(scheme); + final boolean loopbackHttp = "http".equalsIgnoreCase(scheme) && isLoopbackHost(uri.getHost(), loopbackHosts); + if (!https && !loopbackHttp) { + return error("invalid_request", "Redirect URI must use HTTPS (plain HTTP allowed only for localhost): " + uriStr); + } + + // Path wildcard check + String path = uri.getPath(); + if (path != null && path.contains("*") && !path.endsWith("*")) { + 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; + } + + private static boolean isLoopbackHost(String host, Set loopbackHosts) { + if (host == null) { + return false; + } + // Strip brackets from an IPv6 literal (e.g. [::1]). + final String h = host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host; + // Exact, case-insensitive match against the single loopback-host set (defaults + configured extras). + // No sub/parent-domain widening: only hosts explicitly listed get the plain-HTTP exception. + return loopbackHosts.contains(h.toLowerCase(Locale.ROOT)); + } + + @Override + 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..01e5517ef7 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java @@ -0,0 +1,682 @@ +/* + * 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.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; +import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory; +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.AliasService; +import org.apache.knox.gateway.services.security.AliasServiceException; +import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder; +import org.apache.knox.gateway.services.security.token.JWTokenAuthority; +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.services.security.token.impl.TokenMAC; +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.CLIENT_SECRET; +import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.AUTH_CODE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESOURCE_PATH; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE; +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_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_RESOURCE_PATH + "/token"; + + // Per-request stash for the auth-code TokenMetadata read during validation. The code is + // atomically consumed (deleted) BEFORE token issuance to close the replay window, so the + // issuance steps (buildUserContext/addArbitraryTokenMetadata/buildResponseMap) can no longer + // re-read it from the store; they read this request attribute instead. This resource is a + // singleton, but the @Context request is a per-request proxy, so the attribute is request-scoped. + private static final String AUTH_CODE_METADATA_ATTR = "knoxidf.authCode.metadata"; + + // Per-request stash for the federated identity id of the current grant. On the authorization_code + // grant it is read from the auth-code metadata; on the refresh_token grant it is restored from the + // presented refresh token's metadata (see handleRefreshToken). This lets id_token generation keep + // emitting federated profile claims, and lets the rotated refresh token carry the id forward, so + // federated claims survive an arbitrary number of refresh rotations. + private static final String FEDERATED_IDENTITY_ID_ATTR = "knoxidf.federatedIdentityId"; + + private UserParamsProvider userParamsProvider; + + @Context + HttpServletRequest request; // package-private for test injection; @Context injection is reflective + + @Context + private ServletContext servletContext; + + private FederatedIdentityService federatedIdentityService; + private long refreshTokenTTL; + TokenMAC tokenMAC; + + @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); + // Build the same passcode MAC the JWTFederationFilter uses so the token endpoint can + // independently authenticate a client_secret (see validateAuthCode). The HMAC key alias is + // guaranteed to exist by this point (PasscodeTokenResourceBase#setupTokenStateService + // generates it if absent). + final GatewayConfig gatewayConfig = (GatewayConfig) servletContext.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + final AliasService aliasService = services.getService(ServiceType.ALIAS_SERVICE); + this.tokenMAC = new TokenMAC(gatewayConfig.getKnoxTokenHashAlgorithm(), aliasService.getPasswordFromAliasForGateway(TokenMAC.KNOX_TOKEN_HASH_KEY_ALIAS_NAME)); + setRefreshTokenTTL(); + } + + 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 super.doPost(); // with this, we don't need an additional KNOXTOKEN service in any KnoxIDF topology + } + + private boolean isAuthCodeFlow() { + return isAuthCodeFlow(getRequestParam(GRANT_TYPE)); + } + + private boolean isAuthCodeFlow(String grantType) { + return AUTH_CODE.equals(grantType); + } + + private boolean isRefreshTokenFlow() { + return REFRESH_TOKEN.equals(getRequestParam(GRANT_TYPE)); + } + + @Override + protected UserContext buildUserContext(HttpServletRequest request) { + if (isAuthCodeFlow()) { + try { + final TokenMetadata tokenMetadata = getAuthCodeMetadata(); + 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); + } + } + return super.buildUserContext(request); + } + + @Override + protected void addArbitraryTokenMetadata(TokenMetadata tokenMetadata) { + super.addArbitraryTokenMetadata(tokenMetadata); + if (isAuthCodeFlow()) { + try { + final String code = getRequestParam(CODE); + if (StringUtils.isNotBlank(code)) { + final TokenMetadata authCodeTokenMetadata = getAuthCodeMetadata(); + + //if the auth code token was a result of a federated OIDC call, we need to save the associated + //federated identity ID in the JWT too (so that it can be looked up while fetching user info) + final String federatedIdentityId = authCodeTokenMetadata.getMetadata(FEDERATED_IDENTITY_ID); + if (StringUtils.isNotBlank(federatedIdentityId)) { + tokenMetadata.add(FEDERATED_IDENTITY_ID, federatedIdentityId); + } + } + } catch (UnknownTokenException e) { + //this should not happen as we have just validated the auth code + throw new RuntimeException(e); + } + } + } + + @Override + protected ResponseMap buildResponseMap(JWT token, long expires) throws TokenServiceException { + final ResponseMap responseMap = super.buildResponseMap(token, expires); + + // id_token + refresh-token rotation apply to the user-centric grants (authorization_code and + // refresh_token). client_credentials and other grants routed to super.doPost() must not get an + // id_token (no end user) and never carry offline_access, so they are excluded here. + if (isAuthCodeFlow() || isRefreshTokenFlow()) { + final String code = getRequestParam(CODE); + TokenMetadata authCodeTokenMetadata = null; + if (StringUtils.isNotBlank(code)) { + try { + authCodeTokenMetadata = getAuthCodeMetadata(); + } catch (UnknownTokenException e) { + //NOP + } + } + + responseMap.map.put("id_token", generateIdToken(token, authCodeTokenMetadata)); + + final String refreshToken = generateRefreshToken(token); + if (StringUtils.isNotBlank(refreshToken)) { + responseMap.map.put(REFRESH_TOKEN, refreshToken); + } + } + + return responseMap; + } + + // Package-private for testability (the single-use rotation guard is exercised by + // TokenResourceRefreshTokenRotationTest); not part of the public resource API. + Response handleRefreshToken() { + // Audit the outcome of every refresh_token grant exactly once. The resource is the masked + // client_id; the masked refresh-token id and a reason are recorded in the message. The raw + // refresh token and client_secret are never logged. + final String clientId = getRequestParam(CLIENT_ID); + String maskedRefreshTokenId = KnoxIDFAudit.UNKNOWN; + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; + try { + final String refreshTokenParam = getRequestParam(REFRESH_TOKEN); + final String refreshTokenId = TokenUtils.getTokenId(refreshTokenParam); + maskedRefreshTokenId = KnoxIDFAudit.mask(refreshTokenId); + final TokenMetadata refreshTokenMetadata = tokenStateService.getTokenMetadata(refreshTokenId); + validateRefreshTokenGrant(refreshTokenParam, refreshTokenId, refreshTokenMetadata); + + // Rotation is single-use: atomically consume (revoke) the presented refresh token BEFORE + // issuing its replacement. consumeToken is an atomic claim -- exactly one of N concurrent + // redemptions wins -- so two concurrent refreshes cannot both mint a new token pair from + // the same refresh token. (DefaultTokenStateService otherwise has a check-then-act race in + // revokeToken; the JDBC path is already atomic via a PK DELETE.) A lost claim means another + // request already redeemed/rotated this token, so reject it as invalid_grant. This mirrors + // the consume-before-issue guard on the authorization_code grant (see handleAuthorizationCodeFlow). + if (!tokenStateService.consumeToken(refreshTokenId)) { + detail = "reason=refresh_token_replayed"; + return error("invalid_grant", "Refresh token has already been redeemed"); + } + + // Valid, freshly-consumed refresh token -> issue new access token and new refresh token (rotation) + final String userName = refreshTokenMetadata.getUserName(); + final String scope = refreshTokenMetadata.getMetadata(SCOPE); + final Map userParams = userParamsProvider.getParamsFor(userName, scope); + userParams.put(SCOPE, scope); + + // Restore the federated identity id (if any) so id_token generation keeps the federated + // profile claims and the rotated refresh token carries the id forward for the next refresh. + final String federatedIdentityId = refreshTokenMetadata.getMetadata(FEDERATED_IDENTITY_ID); + if (StringUtils.isNotBlank(federatedIdentityId)) { + request.setAttribute(FEDERATED_IDENTITY_ID_ATTR, federatedIdentityId); + } + + // Build new tokens + final UserContext userContext = new UserContext(userName, null, userParams); + final TokenResponseContext resp = getTokenResponse(userContext); + outcome = ActionOutcome.SUCCESS; + detail = "reason=rotated"; + return resp.build(); + } catch (ParseException e) { + detail = "reason=malformed_refresh_token"; + return error("invalid_grant", "Malformed refresh_token"); + } catch (UnknownTokenException e) { + detail = "reason=unknown_refresh_token"; + return error("invalid_grant", "Unknown refresh_token"); + } catch (RefreshTokenValidationError e) { + detail = "reason=validation_failed"; + return error("invalid_grant", e.getMessage()); + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + outcome, "event=token_grant grant_type=refresh_token refresh_token_id=" + + maskedRefreshTokenId + " " + detail); + } + } + + // Package-private for testability (client-authentication on the refresh grant is exercised by + // TokenResourceRefreshTokenClientAuthTest); not part of the public resource API. + void validateRefreshTokenGrant(String refreshTokenParam, String refreshTokenId, TokenMetadata refreshTokenMetadata) throws UnknownTokenException, RefreshTokenValidationError { + final String clientId = getRequestParam(CLIENT_ID); + + if (StringUtils.isBlank(refreshTokenParam)) { + 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"); + } + + // A refresh token that has been administratively disabled (revoked) must not mint new tokens, + // even if it has not yet expired. + if (!refreshTokenMetadata.isEnabled()) { + throw new RefreshTokenValidationError("Invalid grant: refresh_token disabled"); + } + + if (tokenStateService.getTokenExpiration(refreshTokenId) <= System.currentTimeMillis()) { + throw new RefreshTokenValidationError("Invalid grant: Refresh token expired"); + } + + final String associatedClientId = refreshTokenMetadata.getMetadata(CLIENT_ID); + if (!clientId.equals(associatedClientId)) { + throw new RefreshTokenValidationError("Invalid grant: client_id mismatch"); + } + + // Client authentication (RFC 6749 §6, §10.4). Like the authorization_code grant + // (see validateAuthCode), the refresh_token grant must independently prove client identity: + // the JWTFederationFilter Bearer path forwards a request to this endpoint without checking + // client_secret, so matching client_id alone would let anyone holding a stolen refresh token + // redeem and rotate it. KnoxIDF issues every registered client a client_secret, so a + // constant-time client_secret check against the stored passcode is required here. + if (!isValidClientSecret(clientId, getRequestParam(CLIENT_SECRET))) { + throw new RefreshTokenValidationError("Invalid grant: client authentication failed"); + } + } + + // Package-private for testability (single-use replay guard is exercised by + // TokenResourceAuthCodeReplayTest); not part of the public resource API. + Response handleAuthorizationCodeFlow() { + final String code = getRequestParam(CODE); + final String redirectUri = getRequestParam(REDIRECT_URI); + // Audit the outcome of every authorization_code grant exactly once. The resource is the masked + // client_id; the masked auth-code id and a reason are recorded in the message. The raw code, + // code_verifier and client_secret are never logged. + final String clientId = getRequestParam(CLIENT_ID); + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; + try { + final TokenMetadata authCodeMetadata; + try { + authCodeMetadata = validateAuthCode(code, redirectUri); + } catch (AuthTokenValidationError e) { + detail = "reason=validation_failed"; + return error("invalid_grant", e.getMessage()); + } + + // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent + // redemptions of the same code, exactly one wins the consume and proceeds; the losers get + // invalid_grant. This closes the replay window that existed when the code was only revoked + // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT + // consumed here, so replaying with bad params cannot burn a victim's still-valid code. + if (!tokenStateService.consumeToken(code)) { + detail = "reason=code_replayed"; + return error("invalid_grant", "Authorization code has already been redeemed"); + } + + // The code is now gone from the store; hand the already-validated metadata to the issuance + // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code. + request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata); + final Response response = getAuthenticationToken(); + outcome = ActionOutcome.SUCCESS; + detail = "reason=tokens_issued"; + return response; + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + outcome, "event=token_grant grant_type=authorization_code code=" + KnoxIDFAudit.mask(code) + + " " + detail); + } + } + + /** + * Returns the auth-code {@link TokenMetadata} captured at validation time and stashed in a + * request attribute by {@link #handleAuthorizationCodeFlow()}. Because the code is consumed + * (deleted) before token issuance, the issuance steps can no longer re-read it from the store; + * this serves the cached copy, falling back to a store read only if the attribute is absent. + */ + private TokenMetadata getAuthCodeMetadata() throws UnknownTokenException { + final Object cached = request.getAttribute(AUTH_CODE_METADATA_ATTR); + if (cached instanceof TokenMetadata) { + return (TokenMetadata) cached; + } + return tokenStateService.getTokenMetadata(getRequestParam(CODE)); + } + + // Resolves the federated identity id for the current grant, or null for a local (non-federated) + // user. On the authorization_code grant it comes from the auth-code metadata; on the refresh_token + // grant it is restored from the presented refresh token's metadata via a request attribute + // (see handleRefreshToken). This keeps federated profile claims flowing through every refresh. + private String resolveFederatedIdentityId() { + if (isAuthCodeFlow()) { + try { + return getAuthCodeMetadata().getMetadata(FEDERATED_IDENTITY_ID); + } catch (UnknownTokenException e) { + //this should not happen as we have just validated the auth code + throw new RuntimeException(e); + } + } + final Object cached = request.getAttribute(FEDERATED_IDENTITY_ID_ATTR); + return cached == null ? null : cached.toString(); + } + + private TokenMetadata 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); + } + + final String associatedClientId = authCodeTokenMetadata.getMetadata(CLIENT_ID); + final String clientId = getRequestParam(CLIENT_ID); + if (!associatedClientId.equals(clientId)) { + throw new AuthTokenValidationError("Invalid client_id: " + clientId); + } + + // Client authentication (defense in depth). A stolen auth code must not be redeemable by + // a party that merely holds some valid Knox JWT: the JWTProvider (JWTFederationFilter) + // Bearer path forwards such a request to this endpoint without ever checking + // client_secret. So the token endpoint independently binds the redemption to the + // legitimate client here. The caller must prove client identity via EITHER: + // - PKCE: a code_verifier matching the challenge stored at authorize time (S256), or + // - the client's client_secret (constant-time compared against the stored passcode). + // A code is rejected when neither is satisfiable. + final String codeChallenge = authCodeTokenMetadata.getMetadata(CODE_CHALLENGE); + if (StringUtils.isNotBlank(codeChallenge)) { + final String codeChallengeMethod = authCodeTokenMetadata.getMetadata(CODE_CHALLENGE_METHOD); + 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"); + } + } else if (!isValidClientSecret(clientId, getRequestParam(CLIENT_SECRET))) { + throw new AuthTokenValidationError("Invalid client authentication"); + } + return authCodeTokenMetadata; + } catch (UnknownTokenException e) { + throw new AuthTokenValidationError("Unknown auth_code"); + } + } + + /** + * Authenticates a confidential client on the token endpoint by validating the presented + * {@code client_secret} against the stored passcode of the client identified by {@code clientId}. + *

+ * The wire format of {@code client_secret} matches what registration returns and what + * {@link org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter} expects: + * {@code Base64(Base64(tokenId)::Base64(rawPasscode))}. The embedded {@code tokenId} must equal + * {@code clientId}, and {@code HMAC(tokenId, issueTime, userName, rawPasscode)} must equal the + * stored passcode hash. The comparison is constant-time. + * + * @return {@code true} only if the secret is well-formed, bound to {@code clientId}, and matches. + */ + boolean isValidClientSecret(final String clientId, final String clientSecret) { + if (StringUtils.isBlank(clientId) || StringUtils.isBlank(clientSecret)) { + return false; + } + try { + final String[] tokenIdAndPasscode = decodeBase64(clientSecret).split("::"); + if (tokenIdAndPasscode.length != 2) { + return false; + } + final String tokenId = decodeBase64(tokenIdAndPasscode[0]); + final String rawPasscode = decodeBase64(tokenIdAndPasscode[1]); + // The client_secret must belong to exactly the client redeeming the code. + if (!tokenId.equals(clientId)) { + return false; + } + final TokenMetadata clientMetadata = tokenStateService.getTokenMetadata(tokenId); + final String storedPasscode = clientMetadata == null ? null : clientMetadata.getPasscode(); + if (StringUtils.isBlank(storedPasscode)) { + return false; + } + final long issueTime = tokenStateService.getTokenIssueTime(tokenId); + final String userName = clientMetadata.getUserName(); + final byte[] computed = tokenMAC.hash(tokenId, issueTime, userName, rawPasscode).getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(computed, storedPasscode.getBytes(StandardCharsets.UTF_8)); + } catch (UnknownTokenException | RuntimeException e) { + return false; + } + } + + private String decodeBase64(final String value) { + return new String(Base64.getDecoder().decode(value.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + } + + private boolean validatePKCE(String codeVerifier, String codeChallenge, String method) { + // Only S256 is supported. 'plain' provides no protection and is rejected (the authorize + // endpoint already refuses to store a non-S256 challenge; this is defense in depth). + if (PKCE_METHOD_S256.equals(method)) { + try { + return generateS256Challenge(codeVerifier).equals(codeChallenge); + } catch (NoSuchAlgorithmException e) { + 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 { + // The federated identity id is resolved for the whole grant (auth-code metadata on the + // authorization_code grant, restored refresh-token metadata on the refresh_token grant), so a + // federated user keeps their profile claims in the id_token across refresh-token rotations. + final String federatedIdentityId = resolveFederatedIdentityId(); + + if (StringUtils.isNotBlank(federatedIdentityId)) { + // client_id and nonce live on the auth-code metadata for the authorization_code grant; on + // the refresh_token grant there is no auth-code metadata, so client_id comes from the + // request and there is no nonce to echo (nonce binds the original authorization request). + final String clientId = authCodeTokenMetadata != null + ? authCodeTokenMetadata.getMetadata(CLIENT_ID) : getRequestParam(CLIENT_ID); + final String nonce = authCodeTokenMetadata != null ? authCodeTokenMetadata.getMetadata("nonce") : null; + return generateFederatedIdToken(accessToken, federatedIdentityId, clientId, nonce); + } else { + return generateLocalIdToken(accessToken, authCodeTokenMetadata); + } + } + + private String generateFederatedIdToken(JWT accessToken, String fedIdentityId, String clientId, String nonce) throws TokenServiceException { + 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(clientId); + + final Map claims = new HashMap<>(federatedIdentity.getAttributes()); + claims.keySet().retainAll(AuthorizeResource.ALLOWED_CLAIMS); + 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); + // Carry the federated identity id onto the refresh token so that, after rotation, the + // refresh_token grant can still emit federated profile claims in the id_token (the rotated + // token has no auth code to read the id back from). Blank/absent for local users. + final String federatedIdentityId = resolveFederatedIdentityId(); + if (StringUtils.isNotBlank(federatedIdentityId)) { + metadata.add(FEDERATED_IDENTITY_ID, federatedIdentityId); + } + 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); + } + } + + // Package-private so TokenResourceRefreshTokenClientAuthTest can assert the specific failure type. + static class RefreshTokenValidationError extends Exception { + RefreshTokenValidationError(String message) { + super(message); + } + } +} diff --git a/gateway-service-knoxidf/src/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..cbfaff4013 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java @@ -0,0 +1,231 @@ +/* + * 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.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/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 RegisterIssuerRequest parsed; + try { + parsed = MAPPER.readValue(body, RegisterIssuerRequest.class); + } catch (IOException e) { + return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", + "Malformed or invalid JSON body"); + } + + final String rawUrl = parsed.getIssuerUrl(); + 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); + } + + 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"); + } 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/UserInfoResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java new file mode 100644 index 0000000000..320f797859 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java @@ -0,0 +1,173 @@ +/* + * 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.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; +import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory; +import org.apache.knox.gateway.services.GatewayServices; +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.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_RESOURCE_PATH; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE_ATTRIBUTE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_ID_ATTRIBUTE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error; + + +@Path(UserInfoResource.RESOURCE_PATH) +@Produces(MediaType.APPLICATION_JSON) +public class UserInfoResource { + + static final String RESOURCE_PATH = BASE_RESOURCE_PATH + "/userinfo"; + private UserParamsProvider userParamsProvider; + + @Context + private ServletContext servletContext; + + @Context + 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() { + return getUserInfo(); + } + + public Response doPost() { + throw new UnsupportedOperationException(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getUserInfo() { + final String tokenId = request.getAttribute(TOKEN_ID_ATTRIBUTE) == null ? null : request.getAttribute(TOKEN_ID_ATTRIBUTE).toString(); + // Audit the outcome of every /userinfo access exactly once. The resource is the masked + // bearer-token id (never the raw token); the reason distinguishes the failure modes. + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; + try { + if (tokenId == null) { + detail = "reason=missing_token_id"; + return error("invalid_request", "Cannot find tokenId"); + } + + final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString(); + final TokenMetadata tokenMetadata; + try { + tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId); + } catch (UnknownTokenException e) { + // Expired, revoked, or otherwise unknown bearer token. Per RFC 6750 the protected + // resource must answer 401 with a WWW-Authenticate: Bearer error="invalid_token" + // challenge rather than leaking a 500 for what is a client authentication failure. + detail = "reason=invalid_token"; + return invalidToken("The access token is expired, revoked, or unknown"); + } + final Map userInfo = new HashMap<>(); + + // Check if this token has a federated identity + final String federatedIdentityId = tokenMetadata.getMetadata("federated_identity_id"); + + if (StringUtils.isNotBlank(federatedIdentityId)) { + // Federated user + final FederatedIdentity federatedIdentity = federatedIdentityService + .findById(federatedIdentityId) + .orElse(null); + if (federatedIdentity == null) { + // The token references a federated identity that no longer exists; the bearer token + // can no longer be honored, so answer with the RFC 6750 invalid_token challenge. + detail = "reason=unknown_federated_identity"; + return invalidToken("The access token references an unknown federated identity"); + } + + // Include only allowed claims + Map claims = federatedIdentity.getAttributes().entrySet().stream() + .filter(e -> AuthorizeResource.ALLOWED_CLAIMS.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + // Mandatory claims for OIDC + claims.put("sub", federatedIdentity.getUserId()); // internal Knox subject + claims.put("idp", federatedIdentity.getProvider()); + + // Optional: federated info for auditing + claims.put("federated_sub", federatedIdentity.getExternalSubject()); + claims.put("federated_iss", federatedIdentity.getExternalIssuer()); + + // Note: nonce is deliberately NOT returned here. Per OIDC it belongs in the id_token + // only; echoing it from the UserInfo endpoint is a spec violation and serves no purpose. + + userInfo.putAll(claims); + } else { + // Local Knox user + userInfo.putAll(userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope)); + } + + outcome = ActionOutcome.SUCCESS; + detail = "reason=served"; + return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build(); + } finally { + KnoxIDFAudit.audit(Action.ACCESS, KnoxIDFAudit.mask(tokenId), ResourceType.URI, outcome, + "event=userinfo " + detail); + } + } + + TokenStateService getReadonlyTokenStateService() { + GatewayServices services = (GatewayServices) servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE); + return services.getService(ServiceType.TOKEN_STATE_SERVICE); + } + + /** + * Builds the RFC 6750 §3 response for a bad bearer token: HTTP 401 with a + * {@code WWW-Authenticate: Bearer error="invalid_token"} challenge and a matching JSON body. + */ + static Response invalidToken(final String description) { + final Response base = error("invalid_token", description, Response.Status.UNAUTHORIZED); + final String challenge = "Bearer error=\"invalid_token\", error_description=\"" + description + "\""; + return Response.fromResponse(base).header("WWW-Authenticate", challenge).build(); + } + +} + diff --git a/gateway-service-knoxidf/src/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..639027e8bb --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java @@ -0,0 +1,55 @@ +/* + * 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 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 { + + @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/admin/**?**" }; + } +} 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..1f78777c30 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/userparams/LdapUserParamsProvider.java @@ -0,0 +1,235 @@ +/* + * 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.naming.ldap.Rdn; +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[] 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); + // No hardcoded fallback: if the alias is absent/unresolvable, return null so the LDAP + // bind fails fast rather than silently binding with a well-known demo password. + return systemPassword == null ? null : new String(systemPassword); + } catch (AliasServiceException e) { + return null; + } + } + + @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(); + + // Escape the subject before interpolating it into the DN template. Without escaping a + // crafted subject (e.g. "x,ou=admins") could inject additional DN components (LDAP + // injection). Rdn.escapeValue escapes the RDN value per RFC 2253. + String userDn = String.format(Locale.US, ldapUserDnTemplate, Rdn.escapeValue(subjectName)); + + SearchControls controls = new SearchControls(); + controls.setSearchScope(SearchControls.OBJECT_SCOPE); + controls.setReturningAttributes(ATTRIBUTES); + + // Always close the enumeration: LdapContext.close() alone does not release the + // per-search cursor, so leaking these under load can exhaust server-side resources + // and start throwing NamingException. NamingEnumeration is not AutoCloseable, hence + // the explicit try/finally rather than try-with-resources. + NamingEnumeration results = ctx.search(userDn, "(objectClass=*)", controls); + try { + 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); + } + } + } finally { + closeEnumeration(results); + } + + } 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; + // Close both the group search cursor and each member enumeration; see getParamsFor. + NamingEnumeration groupResults = + ctx.search(groupsBase, "(objectClass=groupOfNames)", groupControls); + try { + while (groupResults.hasMore()) { + SearchResult group = groupResults.next(); + Attributes groupAttrs = group.getAttributes(); + Attribute members = groupAttrs.get("member"); + if (members != null) { + NamingEnumeration e = members.getAll(); + try { + while (e.hasMore()) { + String memberDn = (String) e.next(); + if (memberDn.equalsIgnoreCase(userDn)) { + roles.add(getAttr(groupAttrs, "cn")); + break; + } + } + } finally { + closeEnumeration(e); + } + } + } + } finally { + closeEnumeration(groupResults); + } + return roles; + } + + private LdapContext createSystemContext() throws Exception { + if (ldapSystemPassword == null) { + throw new IllegalStateException("No LDAP system password configured. Set the '" + + LDAP_SYSTEM_PASSWORD_ALIAS + "' alias; there is no default password."); + } + Hashtable 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) { + } + } + } + + private void closeEnumeration(NamingEnumeration enumeration) { + if (enumeration != null) { + try { + enumeration.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..1fca75abb8 --- /dev/null +++ b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor @@ -0,0 +1,19 @@ +########################################################################## +# 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 +org.apache.knox.gateway.service.knoxidf.deploy.KnoxIDFAdminServiceDeploymentContributor diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.java new file mode 100644 index 0000000000..3915d7dd78 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceClientSecretResolutionTest.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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.apache.knox.gateway.service.knoxidf.AuthorizeResource.ClientSecretResolutionException; +import org.junit.Test; + +/** + * Verifies the fail-closed handling of a configured federated-OP client-secret alias (review + * finding M4). When an alias is declared but resolves to nothing, the token exchange must abort + * with a clear error and never contact the OP -- previously an unresolvable alias yielded a null + * secret that the form encoder serialized to a literal {@code client_secret=null} sent to the OP. + */ +public class AuthorizeResourceClientSecretResolutionTest { + + @Test + public void testResolvedSecretIsReturned() { + final String secret = AuthorizeResource.requireResolvedAliasSecret("op.secret.alias", "s3cr3t".toCharArray()); + assertEquals("A resolved alias must yield its secret value.", "s3cr3t", secret); + } + + @Test + public void testUnresolvableAliasFailsClosed() { + try { + AuthorizeResource.requireResolvedAliasSecret("op.secret.alias", null); + fail("A configured-but-unresolvable alias must fail closed, not return null."); + } catch (ClientSecretResolutionException e) { + assertTrue("The error should name the offending alias.", e.getMessage().contains("op.secret.alias")); + } + } + + @Test(expected = ClientSecretResolutionException.class) + public void testEmptyResolvedSecretFailsClosed() { + AuthorizeResource.requireResolvedAliasSecret("op.secret.alias", new char[0]); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java new file mode 100644 index 0000000000..0c2ae15771 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedNonceTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.NONCE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.easymock.EasyMock; +import org.junit.Test; + +/** + * Verifies the federated OIDC {@code nonce} binding (review finding H2). Knox mints a nonce for each + * federated login session, sends it to the OP, and — once the returned id_token's signature/issuer/ + * audience have been verified — requires the id_token's {@code nonce} claim to equal that value. This + * defeats id_token replay/injection: a token minted for a different (or attacker-initiated) request + * carries a different nonce and is rejected. + */ +public class AuthorizeResourceFederatedNonceTest { + + private static final String NONCE_VALUE = "6d1c7a90-4b2e-4c1a-9f3d-0a1b2c3d4e5f"; + + private static JWT idTokenWithNonce(final String nonce) { + final JWT idToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(idToken.getClaim(NONCE)).andReturn(nonce).anyTimes(); + EasyMock.replay(idToken); + return idToken; + } + + @Test + public void testMatchingNoncePasses() { + final Response result = new AuthorizeResource().verifyFederatedNonce(NONCE_VALUE, idTokenWithNonce(NONCE_VALUE)); + assertNull("A matching nonce must pass (null == no error).", result); + } + + @Test + public void testMismatchedNonceIsRejected() { + final Response result = new AuthorizeResource().verifyFederatedNonce(NONCE_VALUE, idTokenWithNonce("some-other-nonce")); + assertNotNull("A mismatched nonce must be rejected.", result); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus()); + } + + @Test + public void testMissingClaimInTokenIsRejected() { + final Response result = new AuthorizeResource().verifyFederatedNonce(NONCE_VALUE, idTokenWithNonce(null)); + assertNotNull("An id_token without a nonce claim must be rejected.", result); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus()); + } + + @Test + public void testMissingExpectedNonceIsRejected() { + // No stored nonce (expired/replayed state) must fail closed rather than accept any token. + final Response result = new AuthorizeResource().verifyFederatedNonce(null, idTokenWithNonce(NONCE_VALUE)); + assertNotNull("A missing expected nonce must be rejected.", result); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus()); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java new file mode 100644 index 0000000000..890abdc01c --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceFederatedSubjectTest.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.easymock.EasyMock; +import org.junit.Test; + +/** + * Verifies that a federated id_token missing the required {@code sub} claim is rejected as a 4xx + * client/OP error rather than surfacing as an HTTP 500 (review finding M2). Knox derives the Knox + * subject and the federated-identity primary key from {@code sub}, and the identity tables declare + * {@code external_subject NOT NULL}; without this guard a broken or hostile OP omitting {@code sub} + * drives a NOT NULL insert failure and a 500 on every callback through that OP. + */ +public class AuthorizeResourceFederatedSubjectTest { + + private static JWT idTokenWithSubject(final String subject) { + final JWT idToken = EasyMock.createNiceMock(JWT.class); + EasyMock.expect(idToken.getSubject()).andReturn(subject).anyTimes(); + EasyMock.replay(idToken); + return idToken; + } + + @Test + public void testPresentSubjectPasses() { + final Response result = new AuthorizeResource().requireFederatedSubject(idTokenWithSubject("user-123")); + assertNull("An id_token carrying a sub claim must pass (null == no error).", result); + } + + @Test + public void testMissingSubjectIsRejectedWith4xx() { + final Response result = new AuthorizeResource().requireFederatedSubject(idTokenWithSubject(null)); + assertNotNull("An id_token without a sub claim must be rejected.", result); + assertEquals("A missing sub must be a client/OP error, not a 500.", + Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus()); + assertTrue("The error body should identify the invalid_request condition.", + String.valueOf(result.getEntity()).contains("invalid_request")); + } + + @Test + public void testBlankSubjectIsRejectedWith4xx() { + final Response result = new AuthorizeResource().requireFederatedSubject(idTokenWithSubject(" ")); + assertNotNull("An id_token with a blank sub claim must be rejected.", result); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), result.getStatus()); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java new file mode 100644 index 0000000000..01c88eaf9e --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceRedirectUriMatchTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.Set; + +import org.junit.Test; + +/** + * Verifies wildcard redirect_uri matching rejects path traversal (review finding M3). A wildcard + * registration such as {@code https://app.example/callback/*} must not match + * {@code https://app.example/callback/../admin}: a raw startsWith on the un-normalized path would + * let the traversal escape the registered prefix and deliver the authorization code to /admin + * (a same-host open redirect). The path is normalized before the prefix compare. + */ +public class AuthorizeResourceRedirectUriMatchTest { + + private final AuthorizeResource resource = new AuthorizeResource(); + + private boolean matches(final String requested, final String registered) { + final Set registeredUris = Collections.singleton(registered); + return resource.matchesRedirectUri(requested, registeredUris); + } + + @Test + public void testTraversalEscapingWildcardPrefixIsRejected() { + assertFalse("A traversal that resolves outside the registered prefix must be rejected.", + matches("https://app.example/callback/../admin", "https://app.example/callback/*")); + } + + @Test + public void testEncodedPrefixSuffixStillMatches() { + assertTrue("A genuine path under the wildcard prefix must still match.", + matches("https://app.example/callback/oauth", "https://app.example/callback/*")); + } + + @Test + public void testExactPrefixMatchesWildcard() { + assertTrue("The wildcard base path itself must match.", + matches("https://app.example/callback/", "https://app.example/callback/*")); + } + + @Test + public void testDifferentOriginIsRejected() { + assertFalse("A same-prefix path on a different host must be rejected.", + matches("https://app.example.evil.com/callback/x", "https://app.example/callback/*")); + } + + @Test + public void testExact(){ + assertTrue("An exact non-wildcard registration must match verbatim.", + matches("https://app.example/cb", "https://app.example/cb")); + assertFalse("An exact registration must not match a different path.", + matches("https://app.example/cb2", "https://app.example/cb")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java new file mode 100644 index 0000000000..30da0eb254 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResourceSuccessRedirectTest.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.net.URI; +import java.util.List; + +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; +import org.junit.Test; + +/** + * Verifies the authorization-code success redirect stays well-formed when the registered + * redirect_uri already carries a query string (review finding M7). Appending {@code ?code=...} + * unconditionally produced a second {@code ?}, so the client parsed neither code nor state. + */ +public class AuthorizeResourceSuccessRedirectTest { + + private static List queryParams(final String location) { + return URLEncodedUtils.parse(URI.create(location), java.nio.charset.StandardCharsets.UTF_8); + } + + private static String param(final List params, final String name) { + return params.stream().filter(p -> p.getName().equals(name)).map(NameValuePair::getValue) + .findFirst().orElse(null); + } + + @Test + public void testRedirectUriWithoutQueryUsesQuestionMark() throws Exception { + final String location = AuthorizeResource.buildSuccessRedirect( + "https://app.example/cb", "the code", "the state"); + assertTrue("A redirect_uri without a query must start its params with '?'.", + location.startsWith("https://app.example/cb?")); + final List params = queryParams(location); + assertEquals("the code", param(params, "code")); + assertEquals("the state", param(params, "state")); + } + + @Test + public void testRedirectUriWithExistingQueryUsesAmpersand() throws Exception { + final String location = AuthorizeResource.buildSuccessRedirect( + "https://app.example/cb?ui=dark", "the code", "the state"); + // Exactly one '?' -- the code/state must be appended with '&', not a second '?'. + assertEquals("There must be exactly one query separator.", 1, location.chars().filter(c -> c == '?').count()); + final List params = queryParams(location); + assertEquals("The pre-existing query param must survive.", "dark", param(params, "ui")); + assertEquals("the code", param(params, "code")); + assertEquals("the state", param(params, "state")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java new file mode 100644 index 0000000000..e4feee4d61 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Verifies the consent metadata key derivation (finding 2.11). Consent is stored in + * {@code KNOX_TOKEN_METADATA.md_name VARCHAR(32)}; the key must therefore stay within 32 chars for + * every subject, be deterministic (so a later read finds an earlier write), and separate distinct + * subjects. + */ +public class ConsentMetadataKeyTest { + + /** The backing column is VARCHAR(32). */ + private static final int MD_NAME_MAX = 32; + + @Test + public void testKeyFitsColumnForRealisticSubjects() { + final String[] subjects = { + "alice", + "administrator@corp.example.com", + // a federated UUID subject - the case that overflowed the old "consentAccepted_" + subject + "b9f8e7d6-c5a4-4321-9876-0123456789abcdef-very-long-external-subject-identifier", + "", + }; + for (final String subject : subjects) { + final String key = AuthorizeResource.consentMetadataKey(subject); + assertTrue("Key '" + key + "' (" + key.length() + " chars) must fit VARCHAR(" + MD_NAME_MAX + ")", + key.length() <= MD_NAME_MAX); + assertTrue("Key should carry the consent_ prefix", key.startsWith("consent_")); + } + } + + @Test + public void testKeyIsDeterministic() { + final String subject = "b9f8e7d6-c5a4-4321-9876-0123456789ab"; + assertEquals("Same subject must always derive the same key (read must match write).", + AuthorizeResource.consentMetadataKey(subject), AuthorizeResource.consentMetadataKey(subject)); + } + + @Test + public void testDistinctSubjectsDeriveDistinctKeys() { + assertNotEquals(AuthorizeResource.consentMetadataKey("alice"), + AuthorizeResource.consentMetadataKey("bob")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java new file mode 100644 index 0000000000..4f5c906899 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourceMetadataTest.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.net.URI; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import org.easymock.EasyMock; +import org.junit.Test; + +/** + * Verifies the discovery document carries required/expected OIDC provider metadata. + * subject_types_supported is REQUIRED by OpenID Connect Discovery 1.0; a strict client or + * conformance validator rejects a document that omits it. registration_endpoint must point at the + * dynamic client registration resource that KnoxIDF actually serves. + */ +public class DiscoveryResourceMetadataTest { + + @Test + public void testAdvertisesSubjectTypesAndRegistrationEndpoint() { + final UriInfo uriInfo = EasyMock.createNiceMock(UriInfo.class); + EasyMock.expect(uriInfo.getBaseUri()).andReturn(URI.create("https://knox:8443/gateway/knoxidf/")).anyTimes(); + EasyMock.replay(uriInfo); + + final Response response = new DiscoveryResource().getConfig(uriInfo); + final String body = String.valueOf(response.getEntity()); + + // subject_types_supported is REQUIRED; Knox uses a shared (non-pairwise) subject -> "public". + assertTrue("subject_types_supported must be present (REQUIRED by OIDC Discovery).", + body.contains("subject_types_supported")); + assertTrue("subject_types_supported must advertise 'public'.", + body.contains("\"public\"")); + + // registration_endpoint must resolve to the dynamic client registration resource. + assertTrue("registration_endpoint must point at the /client registration resource.", + body.contains("registration_endpoint") && body.contains(RegistrationResource.RESOURCE_PATH)); + + // The token endpoint authenticates clients via body params only: client_secret_post + none (PKCE). + assertTrue("token_endpoint_auth_methods_supported must advertise client_secret_post and none.", + body.contains("token_endpoint_auth_methods_supported") + && body.contains("client_secret_post") && body.contains("\"none\"")); + // It must NOT claim HTTP Basic client auth, which the token endpoint does not read. + assertFalse("Discovery must not advertise client_secret_basic, which is not honored.", + body.contains("client_secret_basic")); + + // CIMD is not implemented, so it must be advertised explicitly as false (never true). + assertTrue("client_id_metadata_document_supported must be present and false.", + body.contains("\"client_id_metadata_document_supported\":false")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java new file mode 100644 index 0000000000..dfb1438cf1 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/DiscoveryResourcePkceMethodsTest.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.net.URI; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import org.easymock.EasyMock; +import org.junit.Test; + +/** + * Verifies discovery advertises only the PKCE methods it actually honors (review finding M8). + * AuthorizeResource rejects any code_challenge_method other than S256, so the discovery document + * must not list "plain" -- a client that trusts discovery and sends plain would be rejected at + * /authorize. + */ +public class DiscoveryResourcePkceMethodsTest { + + @Test + public void testCodeChallengeMethodsAdvertisesOnlyS256() { + final UriInfo uriInfo = EasyMock.createNiceMock(UriInfo.class); + EasyMock.expect(uriInfo.getBaseUri()).andReturn(URI.create("https://knox:8443/gateway/knoxidf/")).anyTimes(); + EasyMock.replay(uriInfo); + + final Response response = new DiscoveryResource().getConfig(uriInfo); + final String body = String.valueOf(response.getEntity()); + + assertTrue("Discovery must advertise S256 PKCE support.", + body.contains("code_challenge_methods_supported") && body.contains("S256")); + assertFalse("Discovery must not advertise 'plain' PKCE, which /authorize rejects.", + body.contains("plain")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java new file mode 100644 index 0000000000..5e1a25d824 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java @@ -0,0 +1,280 @@ +/* + * 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 static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.Auditor; +import org.apache.knox.gateway.audit.api.CorrelationContext; +import org.apache.knox.gateway.audit.api.AuditContext; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenMetadataType; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Representative coverage for the KnoxIDF audit instrumentation (structured audit-log completeness). + * A capturing {@link Auditor} is injected into {@link KnoxIDFAudit#auditor} so the emitted + * action/outcome/resource/message can be asserted for a representative SUCCESS path (a rotated + * refresh-token grant) and a representative FAILURE path (a rejected refresh-token grant). It also + * pins the security-critical invariant that {@link KnoxIDFAudit#mask(String)} never echoes a raw + * secret into the record. + */ +public class KnoxIDFAuditTest { + + // A UUID so TokenUtils.getTokenId returns it verbatim (no JWT parsing needed). + private static final String REFRESH_TOKEN_ID = "11111111-2222-3333-4444-555555555555"; + private static final String CLIENT = "client-abc"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + /** Records every thread-local 5-arg audit call so a test can assert what was emitted. */ + static final class CapturingAuditor implements Auditor { + static final class Record { + final String action; + final String resource; + final String resourceType; + final String outcome; + final String message; + + Record(String action, String resource, String resourceType, String outcome, String message) { + this.action = action; + this.resource = resource; + this.resourceType = resourceType; + this.outcome = outcome; + this.message = message; + } + } + + final List records = new ArrayList<>(); + + @Override + public void audit(String action, String resourceName, String resourceType, String outcome, String message) { + records.add(new Record(action, resourceName, resourceType, outcome, message)); + } + + @Override + public void audit(String action, String resourceName, String resourceType, String outcome) { + audit(action, resourceName, resourceType, outcome, null); + } + + @Override + public void audit(CorrelationContext correlationContext, AuditContext auditContext, String action, + String resourceName, String resourceType, String outcome, String message) { + audit(action, resourceName, resourceType, outcome, message); + } + + @Override + public String getServiceName() { + return "knox"; + } + + @Override + public String getComponentName() { + return "knox"; + } + + @Override + public String getAuditorName() { + return "audit"; + } + } + + private static final Auditor ORIGINAL_AUDITOR = KnoxIDFAudit.auditor; + private CapturingAuditor capturingAuditor; + + @Before + public void setUp() { + capturingAuditor = new CapturingAuditor(); + KnoxIDFAudit.auditor = capturingAuditor; + } + + @After + public void tearDown() { + KnoxIDFAudit.auditor = ORIGINAL_AUDITOR; + } + + // --------------------------------------------------------------------------- + // mask(): never echoes a raw secret; blank/unmaskable -> UNKNOWN + // --------------------------------------------------------------------------- + + @Test + public void testMaskNeverLeaksRawValue() { + final String secret = "supersecret-client-secret-value-1234567890"; + final String masked = KnoxIDFAudit.mask(secret); + assertNotNull(masked); + assertFalse("mask() must never return the raw secret", secret.equals(masked)); + assertFalse("mask() output must not contain the full raw secret", masked.contains(secret)); + assertTrue("mask() output must be shorter than the raw secret", masked.length() < secret.length()); + } + + @Test + public void testMaskBlankOrTooShortIsUnknown() { + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask(null)); + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask("")); + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask(" ")); + // Too short for Tokens display text -> normalized to UNKNOWN, never the raw value. + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask("abc")); + } + + // --------------------------------------------------------------------------- + // Representative FAILURE: a rejected refresh-token grant on the token endpoint. + // (Unknown grant types are no longer rejected here -- doPost() delegates them to + // the knoxtoken base, which only issues a token for an already-authenticated caller.) + // --------------------------------------------------------------------------- + + @Test + public void testRejectedRefreshTokenGrantEmitsFailureAudit() throws Exception { + // Missing client_id makes validateRefreshTokenGrant reject the request with invalid_grant, + // which must emit exactly one FAILURE audit record for the refresh_token grant. + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(GRANT_TYPE)).andReturn(REFRESH_TOKEN).anyTimes(); + EasyMock.expect(req.getParameter(REFRESH_TOKEN)).andReturn(REFRESH_TOKEN_ID).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(null).anyTimes(); + EasyMock.replay(req); + + final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.replay(tokenStateService); + + final TestableTokenResource resource = new TestableTokenResource(); + resource.inject(tokenStateService, null, req, null); + + final Response response = resource.handleRefreshToken(); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals("Exactly one audit record must be emitted.", 1, capturingAuditor.records.size()); + final CapturingAuditor.Record record = capturingAuditor.records.get(0); + assertEquals(Action.AUTHENTICATION, record.action); + assertEquals(ResourceType.PRINCIPAL, record.resourceType); + assertEquals(ActionOutcome.FAILURE, record.outcome); + assertTrue(record.message.contains("grant_type=refresh_token")); + assertTrue(record.message.contains("reason=validation_failed")); + } + + // --------------------------------------------------------------------------- + // Representative SUCCESS: a rotated refresh-token grant + // --------------------------------------------------------------------------- + + /** Field injection plus a stub for the token-mint step so only the audit emission is under test. */ + private static final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req, + final UserParamsProvider userParamsProvider) throws Exception { + this.tokenStateService = tss; + this.tokenMAC = mac; + this.request = req; + // userParamsProvider is private on TokenResource and normally wired in init(); inject it + // directly so the rotation success path can build its UserContext without a servlet context. + final Field field = TokenResource.class.getDeclaredField("userParamsProvider"); + field.setAccessible(true); + field.set(this, userParamsProvider); + } + + @Override + protected TokenResponseContext getTokenResponse(final UserContext context) { + return new TokenResponseContext(null, "issued", Response.ok()); + } + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testRefreshTokenRotationEmitsSuccessAudit() throws Exception { + final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + final TokenMetadata refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes(); + EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.replay(refreshTokenMetadata); + + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(REFRESH_TOKEN_ID)).andReturn(refreshTokenMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID)) + .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes(); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes(); + // This redemption wins the atomic consume and rotates. + EasyMock.expect(tokenStateService.consumeToken(REFRESH_TOKEN_ID)).andReturn(true).once(); + EasyMock.replay(tokenStateService); + + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(REFRESH_TOKEN)).andReturn(REFRESH_TOKEN_ID).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes(); + EasyMock.replay(req); + + final UserParamsProvider userParamsProvider = EasyMock.createNiceMock(UserParamsProvider.class); + EasyMock.expect(userParamsProvider.getParamsFor(EasyMock.anyString(), EasyMock.anyObject())) + .andReturn(new HashMap<>()).anyTimes(); + EasyMock.replay(userParamsProvider); + + final TestableTokenResource resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC, req, userParamsProvider); + + final Response response = resource.handleRefreshToken(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("Exactly one audit record must be emitted.", 1, capturingAuditor.records.size()); + final CapturingAuditor.Record record = capturingAuditor.records.get(0); + assertEquals(Action.AUTHENTICATION, record.action); + assertEquals(ResourceType.PRINCIPAL, record.resourceType); + assertEquals(ActionOutcome.SUCCESS, record.outcome); + assertTrue(record.message.contains("grant_type=refresh_token")); + assertTrue(record.message.contains("reason=rotated")); + // Neither the raw refresh token id nor the client_id appear verbatim. + assertFalse(record.message.contains(REFRESH_TOKEN_ID)); + assertFalse("client_id must be masked in the audit record", CLIENT.equals(record.resource)); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java new file mode 100644 index 0000000000..d7cd7fb55b --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFUtilsErrorStatusTest.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils; +import org.junit.Test; + +/** + * Verifies {@link KnoxIDFUtils#error(String, String)} maps each OAuth 2.0 error code to the HTTP + * status RFC 6749 §5.2 prescribes, rather than the previous always-401 behavior. + */ +public class KnoxIDFUtilsErrorStatusTest { + + private static int statusOf(String error) { + return KnoxIDFUtils.error(error, "desc").getStatus(); + } + + @Test + public void testInvalidClientIsUnauthorized() { + assertEquals(401, statusOf("invalid_client")); + } + + @Test + public void testAccessDeniedIsForbidden() { + assertEquals(403, statusOf("access_denied")); + } + + @Test + public void testServerErrorIs500() { + assertEquals(500, statusOf("server_error")); + } + + @Test + public void testTemporarilyUnavailableIs503() { + assertEquals(503, statusOf("temporarily_unavailable")); + } + + @Test + public void testProtocolErrorsDefaultToBadRequest() { + for (final String error : new String[]{"invalid_request", "invalid_grant", "invalid_scope", + "unsupported_grant_type", "unsupported_response_type", "unauthorized_client"}) { + assertEquals("Expected 400 for " + error, 400, statusOf(error)); + } + } + + @Test + public void testUnknownAndNullErrorDefaultToBadRequest() { + assertEquals(400, statusOf("something_unexpected")); + assertEquals(400, statusOf(null)); + } + + @Test + public void testExplicitStatusOverloadWins() { + final Response response = KnoxIDFUtils.error("invalid_request", "desc", Response.Status.CONFLICT); + assertEquals(409, response.getStatus()); + } + + @Test + public void testBodyCarriesErrorCodeAndDescription() { + final String body = String.valueOf(KnoxIDFUtils.error("invalid_grant", "bad code").getEntity()); + assertTrue(body.contains("invalid_grant")); + assertTrue(body.contains("bad code")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java new file mode 100644 index 0000000000..35dd4b4f1c --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationRedirectUriPolicyTest.java @@ -0,0 +1,119 @@ +/* + * 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.junit.Test; + +import javax.ws.rs.core.Response; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Verifies the dynamic-registration redirect-URI policy: HTTPS is required (RFC 8252), plain HTTP is + * tolerated only for loopback dev, and wildcard hosts are rejected. + */ +public class RegistrationRedirectUriPolicyTest { + + private static Response verify(String... uris) { + return RegistrationResource.verifyRedirectUris(Arrays.asList(uris)); + } + + @Test + public void testHttpsAccepted() { + assertNull("A plain https redirect must be accepted.", verify("https://app.example.com/cb")); + } + + @Test + public void testPlainHttpRejectedForNonLoopback() { + final Response response = verify("http://app.example.com/cb"); + assertEquals(400, response.getStatus()); + assertTrue(String.valueOf(response.getEntity()).contains("HTTPS")); + } + + @Test + public void testPlainHttpAllowedForLoopback() { + assertNull(verify("http://localhost:8080/cb")); + assertNull(verify("http://127.0.0.1/cb")); + assertNull(verify("http://[::1]:9000/cb")); + } + + @Test + public void testWildcardHostRejected() { + final Response response = verify("https://*.example.com/cb"); + assertEquals(400, response.getStatus()); + } + + @Test + public void testEmptyListRejected() { + assertEquals(400, verify().getStatus()); + assertEquals(400, RegistrationResource.verifyRedirectUris(Collections.emptyList()).getStatus()); + assertEquals(400, RegistrationResource.verifyRedirectUris((List) null).getStatus()); + } + + @Test + public void testOneBadUriAmongGoodOnesRejectsWhole() { + assertEquals(400, verify("https://good.example.com/cb", "http://evil.example.com/cb").getStatus()); + } + + @Test + public void testConfiguredLoopbackHostAllowsPlainHttp() { + final Set hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + assertNull("A configured loopback host must be allowed over plain HTTP.", + RegistrationResource.verifyRedirectUris(Collections.singletonList("http://host.docker.internal:8443/cb"), hosts)); + } + + @Test + public void testConfiguredLoopbackMatchIsCaseInsensitive() { + final Set hosts = RegistrationResource.parseLoopbackHosts("Host.Docker.Internal"); + assertNull(RegistrationResource.verifyRedirectUris(Collections.singletonList("http://HOST.docker.internal/cb"), hosts)); + } + + @Test + public void testHostNotConfiguredStillRejected() { + final Set hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + assertEquals("A host outside the allowlist must still require HTTPS.", 400, + RegistrationResource.verifyRedirectUris(Collections.singletonList("http://evil.docker.internal/cb"), hosts).getStatus()); + } + + @Test + public void testConfiguredLoopbackDoesNotWidenToSubdomains() { + final Set hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + // Exact match only: a sub-domain of an allowlisted host is NOT itself allowlisted. + assertEquals(400, RegistrationResource.verifyRedirectUris(Collections.singletonList("http://evil.host.docker.internal/cb"), hosts).getStatus()); + } + + @Test + public void testDefaultsAlwaysPresentAlongsideConfiguredHosts() { + final Set hosts = RegistrationResource.parseLoopbackHosts("host.docker.internal"); + // The three hard-coded loopback hosts survive even when extras are configured. + assertNull(RegistrationResource.verifyRedirectUris(Collections.singletonList("http://localhost:8080/cb"), hosts)); + assertNull(RegistrationResource.verifyRedirectUris(Collections.singletonList("http://127.0.0.1/cb"), hosts)); + } + + @Test + public void testBlankConfigYieldsDefaultsOnly() { + assertEquals(RegistrationResource.DEFAULT_LOOPBACK_HOSTS, RegistrationResource.parseLoopbackHosts(null)); + assertEquals(RegistrationResource.DEFAULT_LOOPBACK_HOSTS, RegistrationResource.parseLoopbackHosts(" ")); + assertEquals(RegistrationResource.DEFAULT_LOOPBACK_HOSTS, RegistrationResource.parseLoopbackHosts(" , ,")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java new file mode 100644 index 0000000000..11a84b4c24 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationResourceAnonymousGuardTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.security.PrivilegedAction; + +import javax.security.auth.Subject; + +import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.junit.Test; + +/** + * Verifies finding 1.3: dynamic client registration refuses anonymous callers unless the + * deployment explicitly opts in via {@code knoxidf.client.registration.anonymous.allowed}. The + * endpoint is wired as {@code anon} in the sample topologies, so this resource-level check is what + * keeps registration closed by default. + */ +public class RegistrationResourceAnonymousGuardTest { + + /** Exposes injection of the opt-in flag without running the full JAX-RS/servlet lifecycle. */ + static final class TestableRegistrationResource extends RegistrationResource { + TestableRegistrationResource(final boolean anonymousRegistrationAllowed) { + this.anonymousRegistrationAllowed = anonymousRegistrationAllowed; + } + } + + private static boolean deniedAs(final String principalName, final boolean anonymousAllowed) { + final TestableRegistrationResource resource = new TestableRegistrationResource(anonymousAllowed); + if (principalName == null) { + // No security context at all. + return resource.anonymousRegistrationDenied(); + } + final Subject subject = new Subject(); + subject.getPrincipals().add(new PrimaryPrincipal(principalName)); + return Subject.doAs(subject, (PrivilegedAction) resource::anonymousRegistrationDenied); + } + + @Test + public void testAnonymousCallerRejectedByDefault() { + // Default (flag false): the AnonymousAuthFilter principal ("anonymous") must be turned away. + assertTrue(deniedAs("anonymous", false)); + } + + @Test + public void testAnonymousCallerAllowedWhenExplicitlyEnabled() { + // The deliberate open-registration deployment mode: opt in and the anonymous caller is allowed. + assertFalse(deniedAs("anonymous", true)); + } + + @Test + public void testAuthenticatedCallerAlwaysAllowed() { + // A real authenticated principal is never subject to the anonymous gate, regardless of the flag. + assertFalse(deniedAs("alice", false)); + assertFalse(deniedAs("alice", true)); + } + + @Test + public void testAnonymousMatchIsCaseInsensitive() { + assertTrue(deniedAs("ANONYMOUS", false)); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationScopePolicyTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationScopePolicyTest.java new file mode 100644 index 0000000000..f54c7ebd7b --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/RegistrationScopePolicyTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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.util.knoxidf.KnoxIDFConstants; +import org.junit.Test; + +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Verifies the server-side registerable-scope whitelist: when the operator leaves + * {@code knoxidf.registration.allowed.scopes} unset the OIDC-standard scope set is the bound, and an + * explicit config is parsed into the authoritative set (with {@code openid} always registerable). + */ +public class RegistrationScopePolicyTest { + + @Test + public void testUnsetDefaultsToOidcStandardScopes() { + // Only a genuinely blank (null / whitespace-only) config means "unset" -> OIDC-standard bound. + assertEquals(KnoxIDFConstants.OIDC_STANDARD_SCOPES, RegistrationResource.parseRegisterableScopes(null)); + assertEquals(KnoxIDFConstants.OIDC_STANDARD_SCOPES, RegistrationResource.parseRegisterableScopes(" ")); + } + + @Test + public void testExplicitEmptyListYieldsOpenidOnly() { + // A non-blank config that resolves to no real scopes is an explicit (most restrictive) whitelist, + // not "unset": openid is always registerable, nothing else is. + assertEquals(Set.of("openid"), RegistrationResource.parseRegisterableScopes(" , ,")); + } + + @Test + public void testOidcStandardSetDoesNotIncludeArbitraryScopes() { + // The default bound rejects a self-assigned privileged scope name like 'admin'. + assertFalse(KnoxIDFConstants.OIDC_STANDARD_SCOPES.contains("admin")); + assertTrue(KnoxIDFConstants.OIDC_STANDARD_SCOPES.contains("openid")); + assertTrue(KnoxIDFConstants.OIDC_STANDARD_SCOPES.contains("offline_access")); + } + + @Test + public void testExplicitConfigIsAuthoritativeAndTrimmed() { + final Set scopes = RegistrationResource.parseRegisterableScopes("openid, profile , reports.read"); + assertTrue(scopes.contains("openid")); + assertTrue(scopes.contains("profile")); + assertTrue(scopes.contains("reports.read")); + // A standard scope the operator omitted is NOT registerable under an explicit (narrower) config. + assertFalse(scopes.contains("email")); + assertFalse(scopes.contains("admin")); + } + + @Test + public void testOpenidAlwaysRegisterableEvenIfOmittedFromConfig() { + final Set scopes = RegistrationResource.parseRegisterableScopes("profile,email"); + assertTrue("'openid' must always be registerable regardless of the configured list.", + scopes.contains("openid")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java new file mode 100644 index 0000000000..92e6632ed3 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies the single-use enforcement of the authorization_code grant (finding 2.4). The code must + * be atomically consumed BEFORE any token is issued: exactly one of N concurrent redemptions wins + * the consume and proceeds to issuance; the losers are rejected with {@code invalid_grant} and no + * token is minted. This closes the replay window that existed when the code was only revoked in a + * {@code finally} block after issuance. + */ +public class TokenResourceAuthCodeReplayTest { + + private static final String AUTH_CODE = "auth-code-xyz"; + private static final String CLIENT = "client-abc"; + private static final String REDIRECT = "https://app.example/cb"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + private TokenStateService tokenStateService; + private TestableTokenResource resource; + private final AtomicInteger issuedCount = new AtomicInteger(); + + /** + * Exposes field injection and stubs out the heavy token-issuance path so the replay guard can be + * exercised in isolation. {@code getAuthenticationToken} is the step that mints tokens; here it + * only records that issuance was reached and returns a sentinel. + */ + final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) { + this.tokenStateService = tss; + this.tokenMAC = mac; + this.request = req; + } + + @Override + public Response getAuthenticationToken() { + issuedCount.incrementAndGet(); + return Response.ok("issued").build(); + } + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Before + public void setUp() throws Exception { + final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + // Metadata for the authorization code being redeemed (confidential client, no PKCE challenge). + final TokenMetadata authCodeMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(authCodeMetadata.isAuthCode()).andReturn(true).anyTimes(); + EasyMock.expect(authCodeMetadata.getMetadata(REDIRECT_URI)).andReturn(REDIRECT).anyTimes(); + EasyMock.expect(authCodeMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(authCodeMetadata.getMetadata(CODE_CHALLENGE)).andReturn(null).anyTimes(); + EasyMock.replay(authCodeMetadata); + + // Metadata for the confidential client, used to authenticate the client_secret. + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(AUTH_CODE)).andReturn(authCodeMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenExpiration(AUTH_CODE)) + .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5)).anyTimes(); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes(); + // consumeToken behaviour is set per-test (win vs. lose) before replay(). + + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(CODE)).andReturn(AUTH_CODE).anyTimes(); + EasyMock.expect(req.getParameter(REDIRECT_URI)).andReturn(REDIRECT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes(); + EasyMock.replay(req); + + resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC, req); + } + + @Test + public void testFirstRedemptionConsumesThenIssues() { + EasyMock.expect(tokenStateService.consumeToken(AUTH_CODE)).andReturn(true).once(); + EasyMock.replay(tokenStateService); + + final Response response = resource.handleAuthorizationCodeFlow(); + + assertEquals("A validated, freshly-consumed code should issue a token.", + Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("Exactly one issuance for a single winning redemption.", 1, issuedCount.get()); + EasyMock.verify(tokenStateService); + } + + @Test + public void testReplayedCodeIsRejectedWithoutIssuing() { + // Simulate a concurrent redemption having already consumed the code: consumeToken loses. + EasyMock.expect(tokenStateService.consumeToken(AUTH_CODE)).andReturn(false).once(); + EasyMock.replay(tokenStateService); + + final Response response = resource.handleAuthorizationCodeFlow(); + + assertEquals("A code already consumed by a concurrent redemption must be rejected.", + Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertTrue("The error body should identify the invalid_grant condition.", + String.valueOf(response.getEntity()).contains("invalid_grant")); + assertEquals("A losing redemption must not mint any token.", 0, issuedCount.get()); + EasyMock.verify(tokenStateService); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java new file mode 100644 index 0000000000..7cde797476 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceClientAuthTest.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that the token endpoint independently authenticates a confidential client on the + * authorization_code grant (finding 1.1). This closes the gap where the JWTFederationFilter Bearer + * path forwards a request without checking client_secret: a stolen auth code must not be redeemable + * without proving client identity. + */ +public class TokenResourceClientAuthTest { + + private static final String CLIENT_ID = "client-abc"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + private TokenMAC tokenMAC; + + private TestableTokenResource resource; + + /** + * Exposes injection of the inherited (protected) token-state service and the package-private MAC + * so the client-authentication logic can be exercised without the full JAX-RS/servlet lifecycle. + */ + static final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tokenStateService, final TokenMAC mac) { + this.tokenStateService = tokenStateService; + this.tokenMAC = mac; + } + } + + @Before + public void setUp() throws Exception { + // A deterministic MAC shared by "the server" (stored hash) and the resource under test. + tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT_ID, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT_ID)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT_ID)).andReturn(ISSUE_TIME).anyTimes(); + EasyMock.replay(tokenStateService); + + resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC); + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testValidClientSecretIsAccepted() { + assertTrue(resource.isValidClientSecret(CLIENT_ID, wireSecret(CLIENT_ID, RAW_PASSCODE))); + } + + @Test + public void testWrongPasscodeIsRejected() { + assertFalse(resource.isValidClientSecret(CLIENT_ID, wireSecret(CLIENT_ID, "not-the-real-passcode"))); + } + + @Test + public void testSecretBoundToDifferentClientIsRejected() { + // A secret whose embedded tokenId does not match the client_id redeeming the code must fail, + // even if the secret itself is otherwise well-formed. + assertFalse(resource.isValidClientSecret("some-other-client", wireSecret(CLIENT_ID, RAW_PASSCODE))); + } + + @Test + public void testBlankSecretIsRejected() { + assertFalse(resource.isValidClientSecret(CLIENT_ID, null)); + assertFalse(resource.isValidClientSecret(CLIENT_ID, "")); + } + + @Test + public void testMalformedSecretIsRejected() { + // Not base64 / no "tokenId::passcode" structure. + assertFalse(resource.isValidClientSecret(CLIENT_ID, "!!!not-base64!!!")); + assertFalse(resource.isValidClientSecret(CLIENT_ID, + Base64.getEncoder().encodeToString("no-separator".getBytes(StandardCharsets.UTF_8)))); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java new file mode 100644 index 0000000000..512b5a9d26 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenClientAuthTest.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.knox.gateway.service.knoxidf.TokenResource.RefreshTokenValidationError; +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenMetadataType; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that the token endpoint independently authenticates the client on the + * {@code refresh_token} grant (review finding H1). Matching only {@code client_id} is not enough: + * the JWTFederationFilter Bearer path forwards a request here without checking {@code client_secret}, + * so a stolen refresh token could otherwise be redeemed (and rotated) by anyone. This mirrors the + * client-authentication the authorization_code grant already performs. + */ +public class TokenResourceRefreshTokenClientAuthTest { + + private static final String CLIENT = "client-abc"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + private static final String REFRESH_TOKEN_ID = "refresh-token-id-123"; + private static final String REFRESH_TOKEN_PARAM = "the-opaque-refresh-token"; + + private TokenMAC tokenMAC; + private TokenStateService tokenStateService; + private TokenMetadata refreshTokenMetadata; + + /** Exposes injection of the inherited (protected) token-state service, MAC and request. */ + static final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) { + this.tokenStateService = tss; + this.tokenMAC = mac; + this.request = req; + } + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Before + public void setUp() throws Exception { + tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + // The refresh token's own metadata: a valid, enabled REFRESH_TOKEN bound to CLIENT. + refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes(); + EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.replay(refreshTokenMetadata); + + // The client's registration metadata, used to authenticate the presented client_secret. + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID)) + .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes(); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes(); + EasyMock.replay(tokenStateService); + } + + private TestableTokenResource resourceWithClientSecret(final String clientSecret) { + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(clientSecret).anyTimes(); + EasyMock.replay(req); + + final TestableTokenResource resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC, req); + return resource; + } + + @Test + public void testValidClientSecretPassesRefreshGrant() throws Exception { + final TestableTokenResource resource = resourceWithClientSecret(wireSecret(CLIENT, RAW_PASSCODE)); + // Should complete without throwing: a correctly authenticated client may refresh. + resource.validateRefreshTokenGrant(REFRESH_TOKEN_PARAM, REFRESH_TOKEN_ID, refreshTokenMetadata); + } + + @Test + public void testMissingClientSecretIsRejected() { + final TestableTokenResource resource = resourceWithClientSecret(null); + assertRejected(resource); + } + + @Test + public void testWrongClientSecretIsRejected() { + final TestableTokenResource resource = resourceWithClientSecret(wireSecret(CLIENT, "not-the-real-passcode")); + assertRejected(resource); + } + + @Test + public void testSecretBoundToDifferentClientIsRejected() { + // A well-formed secret whose embedded tokenId is not the refreshing client must not pass. + final TestableTokenResource resource = resourceWithClientSecret(wireSecret("some-other-client", RAW_PASSCODE)); + assertRejected(resource); + } + + private void assertRejected(final TestableTokenResource resource) { + try { + resource.validateRefreshTokenGrant(REFRESH_TOKEN_PARAM, REFRESH_TOKEN_ID, refreshTokenMetadata); + fail("Refresh grant must reject a request that does not authenticate the client."); + } catch (RefreshTokenValidationError expected) { + // expected: client authentication failed + } catch (Exception e) { + fail("Expected RefreshTokenValidationError but got " + e.getClass().getName()); + } + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java new file mode 100644 index 0000000000..90b7346f23 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceRefreshTokenRotationTest.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenMetadataType; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies single-use enforcement of the {@code refresh_token} grant (review finding M1). The + * presented refresh token must be atomically consumed BEFORE its replacement is issued: exactly one + * of N concurrent redemptions wins the consume and rotates; the losers are rejected with + * {@code invalid_grant} and no new token pair is minted. This closes the check-then-act window that + * existed when rotation used {@code revokeToken} on the in-memory backend. Mirrors the + * authorization_code single-use guard (see {@link TokenResourceAuthCodeReplayTest}). + */ +public class TokenResourceRefreshTokenRotationTest { + + // A UUID so TokenUtils.getTokenId returns it verbatim (no JWT parsing needed). + private static final String REFRESH_TOKEN_ID = "11111111-2222-3333-4444-555555555555"; + private static final String CLIENT = "client-abc"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + private TokenStateService tokenStateService; + private TestableTokenResource resource; + private final AtomicInteger issuedCount = new AtomicInteger(); + + /** Field injection plus a stub for the token-mint step so the rotation guard is tested in isolation. */ + final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) { + this.tokenStateService = tss; + this.tokenMAC = mac; + this.request = req; + } + + @Override + protected TokenResponseContext getTokenResponse(final UserContext context) { + issuedCount.incrementAndGet(); + return new TokenResponseContext(null, "issued", Response.ok()); + } + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Before + public void setUp() throws Exception { + final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + final TokenMetadata refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes(); + EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.replay(refreshTokenMetadata); + + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(REFRESH_TOKEN_ID)).andReturn(refreshTokenMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID)) + .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes(); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes(); + // consumeToken behaviour is set per-test (win vs. lose) before replay(). + + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(REFRESH_TOKEN)).andReturn(REFRESH_TOKEN_ID).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes(); + EasyMock.replay(req); + + resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC, req); + } + + @Test + public void testAlreadyRedeemedRefreshTokenIsRejectedWithoutIssuing() { + // A concurrent redemption already consumed the token: this consume loses. + EasyMock.expect(tokenStateService.consumeToken(REFRESH_TOKEN_ID)).andReturn(false).once(); + EasyMock.replay(tokenStateService); + + final Response response = resource.handleRefreshToken(); + + assertEquals("A refresh token already consumed by a concurrent rotation must be rejected.", + Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertTrue("The error body should identify the invalid_grant condition.", + String.valueOf(response.getEntity()).contains("invalid_grant")); + assertEquals("A losing redemption must not mint any token.", 0, issuedCount.get()); + // Proves rotation now goes through the atomic consume path rather than revokeToken. + EasyMock.verify(tokenStateService); + } +} 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..b5cea534ef --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java @@ -0,0 +1,587 @@ +/* + * 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.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); + } + + @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 / + // --------------------------------------------------------------------------- + + @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)); + } + + @SuppressWarnings("unchecked") + private static List> parseJsonList(String json) throws Exception { + return new ObjectMapper().readValue(json, List.class); + } + + 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/UserInfoResourceInvalidTokenTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/UserInfoResourceInvalidTokenTest.java new file mode 100644 index 0000000000..a2c20bc10f --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/UserInfoResourceInvalidTokenTest.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.SCOPE_ATTRIBUTE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.TOKEN_ID_ATTRIBUTE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.UnknownTokenException; +import org.easymock.EasyMock; +import org.junit.Test; + +/** + * Verifies /userinfo answers a bad bearer token per RFC 6750 (review finding M6): an expired, + * revoked, or unknown token must yield HTTP 401 with a + * {@code WWW-Authenticate: Bearer error="invalid_token"} challenge, not a 500 from an unmapped + * RuntimeException. + */ +public class UserInfoResourceInvalidTokenTest { + + private static final String TOKEN_ID = "11111111-2222-3333-[VISA_CARD_NUMBER_REDACTED]"; + + /** Injects the request and a token-state service that throws for an unknown token. */ + static final class TestableUserInfoResource extends UserInfoResource { + private final TokenStateService tss; + + TestableUserInfoResource(final HttpServletRequest req, final TokenStateService tss) { + this.request = req; + this.tss = tss; + } + + @Override + TokenStateService getReadonlyTokenStateService() { + return tss; + } + } + + private static HttpServletRequest requestWithToken(final String tokenId) { + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getAttribute(TOKEN_ID_ATTRIBUTE)).andReturn(tokenId).anyTimes(); + EasyMock.expect(req.getAttribute(SCOPE_ATTRIBUTE)).andReturn(null).anyTimes(); + EasyMock.replay(req); + return req; + } + + @Test + public void testUnknownTokenYields401WithBearerChallenge() throws Exception { + final TokenStateService tss = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tss.getTokenMetadata(TOKEN_ID)).andThrow(new UnknownTokenException(TOKEN_ID)).anyTimes(); + EasyMock.replay(tss); + + final Response response = new TestableUserInfoResource(requestWithToken(TOKEN_ID), tss).getUserInfo(); + + assertEquals("An unknown/expired token must be 401, not 500.", + Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + final Object challenge = response.getHeaderString("WWW-Authenticate"); + assertNotNull("RFC 6750 requires a WWW-Authenticate challenge.", challenge); + assertTrue("The challenge must be a Bearer invalid_token challenge.", + challenge.toString().contains("Bearer") && challenge.toString().contains("invalid_token")); + assertTrue("The JSON body should carry the invalid_token error code.", + String.valueOf(response.getEntity()).contains("invalid_token")); + } + + @Test + public void testMissingTokenIdYieldsInvalidRequest() { + final TokenStateService tss = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.replay(tss); + + final Response response = new TestableUserInfoResource(requestWithToken(null), tss).getUserInfo(); + + assertEquals("A missing token id is a client request error (400).", + Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + } +} 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..578283fa44 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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); + // 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 + 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/admin/**?**", capturedPattern.getValue()); + } +} diff --git a/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/KnoxSSOMessages.java b/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/KnoxSSOMessages.java index 3e642219b1..b9a87cfbe9 100644 --- a/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/KnoxSSOMessages.java +++ b/gateway-service-knoxsso/src/main/java/org/apache/knox/gateway/service/knoxsso/KnoxSSOMessages.java @@ -61,6 +61,10 @@ public interface KnoxSSOMessages { "not valid according to the configured whitelist: {1}. See documentation for KnoxSSO Whitelisting.") void whiteListMatchFail(String original, String whitelist); + @Message( level = MessageLevel.ERROR, text = "The original URL: {0} for redirecting back after authentication " + + "embeds userinfo (e.g. user@host) and is therefore rejected.") + void userInfoInOriginalURL(String original); + @Message( level = MessageLevel.INFO, text = "Knox Token service ({0}) stored state for token {1} ({2})") void storedToken(String topologyName, String tokenDisplayText, String tokenId); } 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..ce2787572c 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,41 @@ import org.apache.knox.gateway.util.Tokens; import org.apache.knox.gateway.util.Urls; import org.apache.knox.gateway.util.WhitelistUtils; +import org.apache.knox.gateway.util.knoxidf.FederatedNonceStore; +import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration; +import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore; +import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils; + +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 java.util.UUID; + +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 +114,15 @@ 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 final FederatedNonceStore federatedNonceStore = FederatedNonceStore.getInstance(120000L); private String sameSiteValue; @@ -226,6 +234,30 @@ 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)); + // Generate a per-request nonce, send it to the OP, and stash it keyed by the login-session id + // (== the state echoed back by the OP). AuthorizeResource.authCallback verifies the returned + // id_token's nonce claim against this value, binding the id_token to this authorization request. + final String nonce = UUID.randomUUID().toString(); + federatedNonceStore.put(loginSessionId, nonce); + final String federatedOpAuthRedirect = KnoxIDFUtils.buildFederatedOpAuthRedirect(federatedOpConfiguration, loginSessionId, nonce); + return Response.seeOther(java.net.URI.create(federatedOpAuthRedirect)).build(); + } else { + return KnoxIDFUtils.error("invalid_request", "Cannot load federated op config associated with login session"); + } + } + @GET @Produces({APPLICATION_JSON, APPLICATION_XML}) public Response doGet() { @@ -264,20 +296,27 @@ private Response getAuthenticationToken(int statusCode) { boolean validRedirect = true; - // If there is a whitelist defined, then the original URL must be validated against it. - // If there is no whitelist, then everything is valid. - if (whitelist != null) { - try { + try { + // A redirect target embedding userinfo (e.g. https://knox-host:8443@evil/) is + // never legitimate; reject it before the host-only whitelist check. + if (Urls.containsUserInfo(original)) { + validRedirect = false; + LOGGER.userInfoInOriginalURL(Log4jAuditor.maskTokenFromURL(original)); + } else if (whitelist != null) { + // If there is a whitelist defined, then the original URL must be validated against it. + // If there is no whitelist, then everything is valid. validRedirect = RegExUtils.checkBaseUrlAgainstWhitelist(whitelist, original); - } catch (MalformedURLException e) { - throw new WebApplicationException("Malformed original URL: " + original, - Response.Status.BAD_REQUEST); + if (!validRedirect) { + LOGGER.whiteListMatchFail(Log4jAuditor.maskTokenFromURL(original), whitelist); + } } + } catch (MalformedURLException e) { + throw new WebApplicationException("Malformed original URL: " + original, + Response.Status.BAD_REQUEST); } if (!validRedirect) { - LOGGER.whiteListMatchFail(Log4jAuditor.maskTokenFromURL(original), whitelist); - throw new WebApplicationException("Original URL not valid according to the configured whitelist.", + throw new WebApplicationException("Original URL not valid for redirect.", Response.Status.BAD_REQUEST); } } else { diff --git a/gateway-service-knoxsso/src/test/java/org/apache/knox/gateway/service/knoxsso/WebSSOResourceTest.java b/gateway-service-knoxsso/src/test/java/org/apache/knox/gateway/service/knoxsso/WebSSOResourceTest.java index f0e1194784..12d84ab0c9 100644 --- a/gateway-service-knoxsso/src/test/java/org/apache/knox/gateway/service/knoxsso/WebSSOResourceTest.java +++ b/gateway-service-knoxsso/src/test/java/org/apache/knox/gateway/service/knoxsso/WebSSOResourceTest.java @@ -473,6 +473,49 @@ public void testWhitelistValidationWithEncodedOriginalURL() throws Exception { } } + @Test + public void testUserInfoOriginalURLRejected() throws Exception { + ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(expectGatewayConfig()).anyTimes(); + + HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getParameter("originalUrl")).andReturn( + "https://localhost:8443%2f@malicious.link/"); + EasyMock.expect(request.getAttribute("targetServiceRole")).andReturn("KNOXSSO").anyTimes(); + EasyMock.expect(request.getParameterMap()).andReturn(Collections.emptyMap()); + EasyMock.expect(request.getServletContext()).andReturn(context).anyTimes(); + EasyMock.expect(request.getServerName()).andReturn("localhost").anyTimes(); + + Principal principal = EasyMock.createNiceMock(Principal.class); + EasyMock.expect(principal.getName()).andReturn("alice").anyTimes(); + EasyMock.expect(request.getUserPrincipal()).andReturn(principal).anyTimes(); + + GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(context.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(services); + + AliasService aliasService = EasyMock.createNiceMock(AliasService.class); + EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).anyTimes(); + EasyMock.expect(aliasService.getPasswordFromAliasForGateway(TokenUtils.SIGNING_HMAC_SECRET_ALIAS)).andReturn(null).anyTimes(); + + JWTokenAuthority authority = new TestJWTokenAuthority(gatewayPublicKey, gatewayPrivateKey); + EasyMock.expect(services.getService(ServiceType.TOKEN_SERVICE)).andReturn(authority); + + HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + ServletOutputStream outputStream = EasyMock.createNiceMock(ServletOutputStream.class); + CookieResponseWrapper responseWrapper = new CookieResponseWrapper(response, outputStream); + + EasyMock.replay(principal, services, context, request); + + WebSSOResource webSSOResponse = new WebSSOResource(); + webSSOResponse.request = request; + webSSOResponse.response = responseWrapper; + webSSOResponse.context = context; + webSSOResponse.init(); + + WebApplicationException e = Assert.assertThrows(WebApplicationException.class, webSSOResponse::doGet); + assertEquals(HttpStatus.SC_BAD_REQUEST, e.getResponse().getStatus()); + } + private GatewayConfig expectGatewayConfig() { return expectGatewayConfig(true); } 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/JWKSResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java index 575caa06cf..4d87e077d3 100644 --- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java +++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java @@ -19,6 +19,7 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; @@ -45,6 +46,9 @@ import java.security.KeyStoreException; import java.security.cert.Certificate; import java.security.interfaces.RSAPublicKey; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import static javax.ws.rs.core.MediaType.APPLICATION_JSON; @@ -80,23 +84,26 @@ public Response getJwksResponse() { } private Response getJwks(final String keystore) { - JWKSet jwks; try { - final RSAPublicKey rsa = getPublicKey(keystore); - /* no public cert found, return empty set */ - if(rsa == null) { - return Response.ok() - .entity(new JWKSet().toJSONObject().toString()).build(); + // Publish one JWK per configured signing-key alias (current key first, then any additional + // verification keys). Each JWK carries its own 'kid' (SHA-256 thumbprint) so a verifier can + // select the right key across a key rotation. A single-key deployment yields exactly one JWK. + final List keys = new ArrayList<>(); + for (final String alias : getSigningKeyAliases()) { + final RSAPublicKey rsa = getPublicKey(keystore, alias); + /* no public cert for this alias, skip it */ + if (rsa == null) { + continue; + } + final String kid = TokenUtils.getThumbprint(rsa, "SHA-256"); + keys.add(new RSAKey.Builder(rsa) + .keyUse(KeyUse.SIGNATURE) + .algorithm(new JWSAlgorithm(this.signatureAlgorithm)) + .keyID(kid) + .build()); } - - final String kid = TokenUtils.getThumbprint(rsa, "SHA-256"); - final RSAKey.Builder builder = new RSAKey.Builder(rsa) - .keyUse(KeyUse.SIGNATURE) - .algorithm(new JWSAlgorithm(this.signatureAlgorithm)) - .keyID(kid); - - jwks = new JWKSet(builder.build()); - + return Response.ok() + .entity(new JWKSet(keys).toString()).type(MediaType.APPLICATION_JSON_TYPE).build(); } catch (KeyStoreException | JOSEException e) { return Response.status(500) .entity("{\n \"error\": \"" + e.toString() + "\"\n}\n").build(); @@ -105,19 +112,31 @@ private Response getJwks(final String keystore) { "{\n \"error\": \"" + "keystore " + keystore + " could not be found." + "\"\n}\n").build(); } - return Response.ok() - .entity(jwks.toString()).type(MediaType.APPLICATION_JSON_TYPE).build(); } protected RSAPublicKey getPublicKey(final String keystore) throws KeystoreServiceException, KeyStoreException { + return getPublicKey(keystore, getSigningKeyAlias()); + } + + protected RSAPublicKey getPublicKey(final String keystore, final String alias) throws KeystoreServiceException, KeyStoreException { final KeyStore ks = keystoreService.getSigningKeystore(keystore); - final Certificate cert = ks.getCertificate(getSigningKeyAlias()); - return (cert != null) ? (RSAPublicKey) cert.getPublicKey() : null; + final Certificate cert = ks.getCertificate(alias); + return (cert != null && cert.getPublicKey() instanceof RSAPublicKey) ? (RSAPublicKey) cert.getPublicKey() : null; + } + + /** + * @return the configured signing-key aliases to publish, falling back to the single default + * signing key when none are configured (backward-compatible single-key behavior). + */ + private List getSigningKeyAliases() { + final GatewayConfig config = (GatewayConfig) context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + final List aliases = (config == null) ? null : config.getSigningKeyAliases(); + return (aliases == null || aliases.isEmpty()) ? Collections.singletonList(getSigningKeyAlias()) : aliases; } private String getSigningKeyAlias() { final GatewayConfig config = (GatewayConfig) context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); - final String alias = config.getSigningKeyAlias(); + final String alias = (config == null) ? null : config.getSigningKeyAlias(); return (alias == null) ? GatewayConfig.DEFAULT_SIGNING_KEY_ALIAS : alias; } diff --git a/gateway-service-knoxtoken/src/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..7b714d3433 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"; @@ -507,6 +537,14 @@ public Response getUserTokens(@Context UriInfo uriInfo) { final String createdBy = uriInfo.getQueryParameters().getFirst("createdBy"); final String userNameOrCreatedBy = uriInfo.getQueryParameters().getFirst("userNameOrCreatedBy"); final boolean allTokens = Boolean.parseBoolean(uriInfo.getQueryParameters().getFirst("allTokens")); + + final String caller = SubjectUtils.getCurrentEffectivePrincipalName(); + if (!isAuthorizedToSeeTokens(caller, userName, createdBy, userNameOrCreatedBy, allTokens)) { + log.unauthorizedGetUserTokensRequest(getTopologyName(), caller); + return Response.status(Response.Status.FORBIDDEN) + .entity("{\n \"error\": \"Caller (" + caller + ") is not authorized to see other users' tokens.\"\n}\n").build(); + } + final Collection userTokens; if (allTokens) { userTokens = tokenStateService.getAllTokens(); @@ -540,6 +578,29 @@ public Response getUserTokens(@Context UriInfo uriInfo) { } } + private boolean isAuthorizedToSeeTokens(String caller, String userName, String createdBy, String userNameOrCreatedBy, boolean allTokens) { + if (StringUtils.isBlank(caller)) { + return false; + } + final GatewayConfig config = (GatewayConfig) context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE); + if (config != null && config.canSeeAllTokens(caller)) { + return true; + } + if (allTokens) { + return false; + } + // an ordinary caller must scope the query to their own tokens + final boolean hasIdentifier = userName != null || createdBy != null || userNameOrCreatedBy != null; + return hasIdentifier + && requestedIsCallerIfPresent(caller, userName) + && requestedIsCallerIfPresent(caller, createdBy) + && requestedIsCallerIfPresent(caller, userNameOrCreatedBy); + } + + private static boolean requestedIsCallerIfPresent(String caller, String requested) { + return requested == null || caller.equals(requested); + } + @GET @Path(GET_TSS_STATUS_PATH) @Produces({APPLICATION_JSON}) @@ -584,7 +645,7 @@ public Response renew(String token) { } else { final String renewer = SubjectUtils.getCurrentEffectivePrincipalName(); - if (tokenRenewalOrRevocationAuthorized(renewer)) { + if (tokenStateChangeAuthorized(renewer)) { try { JWTToken jwt = new JWTToken(token); if (tokenStateService.isExpired(jwt)) { @@ -624,7 +685,7 @@ public Response renew(String token) { return resp; } - private boolean tokenRenewalOrRevocationAuthorized(final String principalName) { + private boolean tokenStateChangeAuthorized(final String principalName) { final boolean userAllowed = allowedRenewers.contains(principalName); final boolean groupAllowed = SubjectUtils.getCurrentGroupPrincipals().stream() .map(GroupPrincipal::getName) @@ -670,12 +731,12 @@ 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."; errorCode = ErrorCode.UNAUTHORIZED; - } else if (triesToRevokeOwnToken(tokenId, revoker) || tokenRenewalOrRevocationAuthorized(revoker)) { + } else if (triesToChangeOwnToken(tokenId, revoker) || tokenStateChangeAuthorized(revoker)) { tokenStateService.revokeToken(tokenId); log.revokedToken(getTopologyName(), Tokens.getTokenDisplayText(token), @@ -715,29 +776,13 @@ private boolean isKnoxSsoCookie(String tokenId) throws UnknownTokenException { return metadata == null ? false : metadata.isKnoxSsoCookie(); } - private boolean triesToRevokeOwnToken(String tokenId, String revoker) throws UnknownTokenException { + private boolean triesToChangeOwnToken(String tokenId, String revoker) throws UnknownTokenException { final TokenMetadata metadata = tokenStateService.getTokenMetadata(tokenId); final String tokenUserName = metadata == null ? "" : metadata.getUserName(); final String tokenCreatedBy = metadata == null ? "" : metadata.getCreatedBy(); 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}) @@ -785,13 +830,19 @@ private Response setTokenEnabledFlags(String tokenIds, boolean enabled) { private Response setTokenEnabledFlag(String tokenId, boolean enable, boolean batch) { String error = ""; ErrorCode errorCode = ErrorCode.UNKNOWN; + Response.Status responseStatus = Response.Status.BAD_REQUEST; if (tokenStateService == null) { error = "Unable to " + (enable ? "enable" : "disable") + " tokens because token management is not configured"; errorCode = ErrorCode.CONFIGURATION_ERROR; } else { try { final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(tokenId); - if (!batch && enable && tokenMetadata.isEnabled()) { + final String caller = SubjectUtils.getCurrentEffectivePrincipalName(); + if (!(triesToChangeOwnToken(tokenId, caller) || tokenStateChangeAuthorized(caller))) { + responseStatus = Response.Status.FORBIDDEN; + error = "Caller (" + caller + ") not authorized to " + (enable ? "enable" : "disable") + " tokens."; + errorCode = ErrorCode.UNAUTHORIZED; + } else if (!batch && enable && tokenMetadata.isEnabled()) { error = "Token is already enabled"; errorCode = ErrorCode.ALREADY_ENABLED; } else if (!batch && !enable && !tokenMetadata.isEnabled()) { @@ -811,11 +862,12 @@ private Response setTokenEnabledFlag(String tokenId, boolean enable, boolean bat } if (error.isEmpty()) { + responseStatus = Response.Status.OK; log.setEnabledFlag(getTopologyName(), enable, Tokens.getTokenIDDisplayText(tokenId)); - return Response.status(Response.Status.OK).entity("{\n \"setEnabledFlag\": \"true\",\n \"isEnabled\": \"" + enable + "\"\n}\n").build(); + return Response.status(responseStatus).entity("{\n \"setEnabledFlag\": \"true\",\n \"isEnabled\": \"" + enable + "\"\n}\n").build(); } else { log.badSetEnabledFlagRequest(getTopologyName(), Tokens.getTokenIDDisplayText(tokenId), error); - return Response.status(Response.Status.BAD_REQUEST).entity("{\n \"setEnabledFlag\": \"false\",\n \"error\": \"" + error + "\",\n \"code\": " + errorCode.toInt() + "\n}\n").build(); + return Response.status(responseStatus).entity("{\n \"setEnabledFlag\": \"false\",\n \"error\": \"" + error + "\",\n \"code\": " + errorCode.toInt() + "\n}\n").build(); } } @@ -845,16 +897,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 { @@ -920,18 +973,19 @@ protected Response onlyAllowGroupsToBeAddedWhenEnabled() { protected UserContext buildUserContext(HttpServletRequest request) { String userName = request.getUserPrincipal().getName(); String createdBy = null; - // checking the doAs user only makes sense if tokens are managed (this is where we store the userName/createdBy information) - // and if impersonation was enabled before (on HadoopAuth or identity-assertion level) so the the current subject has at least one ImpersonatedPrincipal principal - if (tokenStateService != null) { - final Subject subject = SubjectUtils.getCurrentSubject(); - if (subject != null && SubjectUtils.isImpersonating(subject)) { - String primaryPrincipalName = SubjectUtils.getPrimaryPrincipalName(subject); - String impersonatedPrincipalName = SubjectUtils.getImpersonatedPrincipalName(subject); - if (!primaryPrincipalName.equals(impersonatedPrincipalName)) { - createdBy = primaryPrincipalName; - userName = impersonatedPrincipalName; - log.tokenImpersonationSuccess(createdBy, userName); - } + // When impersonation is in effect, the issued token's subject must be the effective (impersonated) + // identity, not the primary principal. This applies to traditional doAs as well as RFC 8693 token + // exchange (where the actor is the primary principal and the subject is the impersonated one). + // This is independent of server-managed state: createdBy is only persisted for managed tokens (see + // persistTokenDetails), so computing it here is harmless when there is no token state service. + final Subject subject = SubjectUtils.getCurrentSubject(); + if (subject != null && SubjectUtils.isImpersonating(subject)) { + String primaryPrincipalName = SubjectUtils.getPrimaryPrincipalName(subject); + String impersonatedPrincipalName = SubjectUtils.getImpersonatedPrincipalName(subject); + if (!primaryPrincipalName.equals(impersonatedPrincipalName)) { + createdBy = primaryPrincipalName; + userName = impersonatedPrincipalName; + log.tokenImpersonationSuccess(createdBy, userName); } } return new UserContext(userName, createdBy); @@ -940,10 +994,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 +1075,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 +1092,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 +1136,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 +1144,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 +1169,18 @@ protected JWT getJWT(String userName, long expires, String jku) throws TokenServ handleDelegatedAuthentication(subject, jwtAttributesBuilder); } + // This resource is a @Singleton, so hardCodedClaimMappings is shared across all requests and + // must never be mutated per-request. Merge the topology-configured mappings with this request's + // user params into a fresh map; otherwise one user's params would leak into other users' tokens. + final Map customAttributes = new HashMap<>(hardCodedClaimMappings); + if (userContext.userParams != null) { + customAttributes.putAll(userContext.userParams); + } + + if (!customAttributes.isEmpty()) { + jwtAttributesBuilder.setCustomAttributes(customAttributes); + } + jwtAttributes = jwtAttributesBuilder.build(); token = ts.issueToken(jwtAttributes); return token; diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java index 5541d26910..192694540a 100644 --- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java +++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResourceV2.java @@ -46,7 +46,7 @@ @Path(TokenResourceV2.RESOURCE_PATH) public class TokenResourceV2 extends TokenResource { - static final String RESOURCE_PATH = "knoxtoken/api/v2/token"; + public static final String RESOURCE_PATH = "knoxtoken/api/v2/token"; // REST endpoints with the same HTTP method diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java index 278e424343..5341edd4ae 100644 --- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java +++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java @@ -77,6 +77,9 @@ void invalidToken(String topologyName, @Message( level = MessageLevel.ERROR, text = "Knox Token service ({0}) rejected a bad set enabled flag request for token {1}: {2}") void badSetEnabledFlagRequest(String topologyName, String tokenId, String error); + @Message( level = MessageLevel.ERROR, text = "Knox Token service ({0}) rejected an unauthorized getUserTokens request from caller ({1})") + void unauthorizedGetUserTokensRequest(String topologyName, String caller); + @Message( level = MessageLevel.DEBUG, text = "Knox Token service ({0}) stored state for token {1} ({2})") void storedToken(String topologyName, String tokenDisplayText, String tokenId); diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java index 966ca85545..0c27238ce6 100644 --- a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java +++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java @@ -26,6 +26,7 @@ import java.security.cert.Certificate; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; import java.util.Collections; import javax.servlet.ServletContext; @@ -37,6 +38,7 @@ import org.apache.knox.gateway.services.ServiceType; import org.apache.knox.gateway.services.security.AliasService; import org.apache.knox.gateway.services.security.KeystoreService; +import org.apache.knox.gateway.services.security.token.TokenUtils; import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder; import org.apache.knox.gateway.services.security.token.impl.JWT; import org.apache.knox.gateway.services.security.token.impl.JWTToken; @@ -133,6 +135,61 @@ public void testE2E() throws Exception { testToken.verify(verifier)); } + /** + * When more than one signing-key alias is configured, the JWKS endpoint must publish one JWK per + * alias, each carrying its own 'kid' (SHA-256 thumbprint), so verifiers can select the right key + * across a manual key rotation. + */ + @Test + public void testMultipleKeysPublished() throws Exception { + /* a second, distinct signing key that stands in for a rotated-out key */ + final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + final RSAPublicKey previousPublicKey = (RSAPublicKey) kpg.generateKeyPair().getPublic(); + + final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class); + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + final GatewayServices svcs = EasyMock.createNiceMock(GatewayServices.class); + final KeystoreService ks = EasyMock.createNiceMock(KeystoreService.class); + final KeyStoreSpi keyStoreSpi = EasyMock.createNiceMock(KeyStoreSpi.class); + final KeyStore keystore = new KeyStoreMock(keyStoreSpi, null, "test"); + keystore.load(null); + EasyMock.expect(ks.getSigningKeystore(null)).andReturn(keystore).anyTimes(); + + /* distinct cert per alias: 'gateway-identity' (current) and 'old-signing-key' (rotated-out) */ + final Certificate currentCert = EasyMock.createNiceMock(Certificate.class); + final Certificate previousCert = EasyMock.createNiceMock(Certificate.class); + EasyMock.expect(keyStoreSpi.engineGetCertificate("gateway-identity")).andReturn(currentCert).anyTimes(); + EasyMock.expect(keyStoreSpi.engineGetCertificate("old-signing-key")).andReturn(previousCert).anyTimes(); + EasyMock.expect(currentCert.getPublicKey()).andReturn(publicKey).anyTimes(); + EasyMock.expect(previousCert.getPublicKey()).andReturn(previousPublicKey).anyTimes(); + + EasyMock.expect(svcs.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(ks).anyTimes(); + EasyMock.expect(svcs.getService(ServiceType.ALIAS_SERVICE)).andReturn(EasyMock.createNiceMock(AliasService.class)).anyTimes(); + EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(svcs).anyTimes(); + final GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class); + EasyMock.expect(config.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes(); + EasyMock.expect(config.getSigningKeyAliases()).andReturn(Arrays.asList("gateway-identity", "old-signing-key")).anyTimes(); + EasyMock.expect(ctx.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(config).anyTimes(); + EasyMock.replay(ctx, req, svcs, ks, keyStoreSpi, currentCert, previousCert, config); + + final JWKSResource jwksResource = new JWKSResource(); + jwksResource.context = ctx; + jwksResource.request = req; + jwksResource.init(); + final Response retResponse = jwksResource.getJwksResponse(); + Assert.assertEquals(Response.Status.OK.getStatusCode(), retResponse.getStatus()); + + final JWKSet jwks = JWKSet.parse(retResponse.getEntity().toString()); + Assert.assertEquals("Expected one JWK per configured alias", 2, jwks.getKeys().size()); + /* both keys are present and addressable by their own kid */ + final String currentKid = TokenUtils.getThumbprint(publicKey, "SHA-256"); + final String previousKid = TokenUtils.getThumbprint(previousPublicKey, "SHA-256"); + Assert.assertNotEquals("The two keys must have distinct kids", currentKid, previousKid); + Assert.assertNotNull("Current key not published under its kid", jwks.getKeyByKeyId(currentKid)); + Assert.assertNotNull("Rotated-out key not published under its kid", jwks.getKeyByKeyId(previousKid)); + } + private JWT getTestToken(final String algorithm) { String[] claimArray = new String[6]; claimArray[0] = "KNOXSSO"; diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java index 281ea90ea6..4658f07d98 100644 --- a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java +++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java @@ -113,6 +113,7 @@ import org.apache.knox.gateway.util.AuthFilterUtils; import org.apache.knox.gateway.util.JsonUtils; import org.easymock.EasyMock; +import org.junit.After; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; @@ -135,10 +136,13 @@ public class TokenServiceResourceTest { private JWTokenAuthority authority; private TestTokenStateService tss = new TestTokenStateService(); private char[] hmacSecret; + private final Set usersCanSeeAllTokens = new HashSet<>(); private enum TokenLifecycleOperation { Renew, - Revoke + Revoke, + Enable, + Disable } @BeforeClass @@ -151,6 +155,11 @@ public static void setUpBeforeClass() throws Exception { privateKey = (RSAPrivateKey) KPair.getPrivate(); } + @After + public void cleanUp() { + this.usersCanSeeAllTokens.clear(); + } + private void configureCommonExpectations(Map contextExpectations) throws Exception { configureCommonExpectations(contextExpectations, null, null); } @@ -206,6 +215,8 @@ private void configureCommonExpectations(Map contextExpectations EasyMock.expect(config.getServiceParameter(tokenStateServiceType, "impl")).andReturn(contextExpectations.get(tokenStateServiceType)).anyTimes(); } EasyMock.expect(config.getKnoxTokenHashAlgorithm()).andReturn(HmacAlgorithms.HMAC_SHA_256.getName()).anyTimes(); + EasyMock.expect(config.canSeeAllTokens(EasyMock.anyObject(String.class))) + .andAnswer(() -> usersCanSeeAllTokens.contains((String) EasyMock.getCurrentArguments()[0])).anyTimes(); EasyMock.expect(config.getMaximumNumberOfTokensPerUser()) .andReturn(contextExpectations.containsKey(KNOX_TOKEN_USER_LIMIT) ? Integer.parseInt(contextExpectations.get(KNOX_TOKEN_USER_LIMIT)) : -1).anyTimes(); EasyMock.expect(services.getService(ServiceType.TOKEN_STATE_SERVICE)).andReturn(tss).anyTimes(); @@ -1045,6 +1056,104 @@ public void testTokenRevocation_Enabled_WithoutUserRenewerOrCorrectGroup() throw "Caller (" + caller + ") not authorized to revoke tokens.", TokenResource.ErrorCode.UNAUTHORIZED); } + @Test + public void testTokenDisable_Enabled_NoSubject() throws Exception { + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder().serviceLevelConfig(true).build(); + final Response response = doTestSetTokenEnabledFlag(configs, false); + validateSetEnabledFlagResponse(response, 403, false, + "Caller (null) not authorized to disable tokens.", TokenResource.ErrorCode.UNAUTHORIZED); + } + + @Test + public void testTokenDisable_Enabled_UnauthorizedCaller() throws Exception { + final String caller = "scott"; + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder() + .serviceLevelConfig(true) + .caller(createTestSubject(caller)) + .build(); + final Response response = doTestSetTokenEnabledFlag(configs, false); + validateSetEnabledFlagResponse(response, 403, false, + "Caller (" + caller + ") not authorized to disable tokens.", TokenResource.ErrorCode.UNAUTHORIZED); + } + + @Test + public void testTokenDisable_Enabled_OwnToken() throws Exception { + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder() + .serviceLevelConfig(true) + .caller(createTestSubject(USER_NAME)) + .build(); + final Response response = doTestSetTokenEnabledFlag(configs, false); + validateSuccessfulSetEnabledFlagResponse(response, false); + } + + @Test + public void testTokenDisable_Enabled_WithRenewerWhitelist() throws Exception { + final String caller = "scott"; + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder() + .serviceLevelConfig(true) + .renewers("tony, dany, steve ," + caller) + .caller(createTestSubject(caller)) + .build(); + final Response response = doTestSetTokenEnabledFlag(configs, false); + validateSuccessfulSetEnabledFlagResponse(response, false); + } + + @Test + public void testTokenDisable_Enabled_WithGroupRenewerWhitelist() throws Exception { + final String caller = "scott"; + final String group = "devOps"; + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder() + .serviceLevelConfig(true) + .groupRenewers(group) + .caller(createTestSubject(caller, group)) + .build(); + final Response response = doTestSetTokenEnabledFlag(configs, false); + validateSuccessfulSetEnabledFlagResponse(response, false); + } + + @Test + public void testTokenEnable_UnauthorizedCallerRejectedBeforeStateCheck() throws Exception { + final String caller = "scott"; + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder() + .serviceLevelConfig(true) + .caller(createTestSubject(caller)) + .build(); + final Response response = doTestSetTokenEnabledFlag(configs, true); + validateSetEnabledFlagResponse(response, 403, false, + "Caller (" + caller + ") not authorized to enable tokens.", TokenResource.ErrorCode.UNAUTHORIZED); + } + + @Test + public void testTokenEnable_AlreadyEnabled_OwnerGetsStateCheck() throws Exception { + final TokenRenewalTestConfigs configs = TokenRenewalTestConfigs.builder() + .serviceLevelConfig(true) + .caller(createTestSubject(USER_NAME)) + .build(); + final Response response = doTestSetTokenEnabledFlag(configs, true); + validateSetEnabledFlagResponse(response, 400, false, + "Token is already enabled", TokenResource.ErrorCode.ALREADY_ENABLED); + } + + @Test + public void testTokenDisable_Enabled_ImpersonatorCanDisableCreatedToken() throws Exception { + final Response response = doTestImpersonatedTokenSetEnabledFlag(createTestSubject(USER_NAME), false); + validateSuccessfulSetEnabledFlagResponse(response, false); + } + + @Test + public void testTokenDisable_Enabled_ImpersonatedUserCanDisableOwnToken() throws Exception { + final Response response = doTestImpersonatedTokenSetEnabledFlag(createTestSubject("impersonatedUserName"), false); + validateSuccessfulSetEnabledFlagResponse(response, false); + } + + @Test + public void testTokenDisable_Enabled_UnauthorizedCallerCannotDisableImpersonatedToken() throws Exception { + final String caller = "scott"; + final Response response = doTestImpersonatedTokenSetEnabledFlag(createTestSubject(caller), false); + validateSetEnabledFlagResponse(response, 403, false, + "Caller (" + caller + ") not authorized to disable tokens.", TokenResource.ErrorCode.UNAUTHORIZED); + } + @Test public void testKidJkuClaims() throws Exception { final Map contextExpectations = new HashMap<>(); @@ -1242,12 +1351,86 @@ private Response getUserTokensResponse(TokenResource tokenResource) { } private Response getUserTokensResponse(TokenResource tokenResource, boolean createdBy) { + return getUserTokensResponse(tokenResource, createTestSubject(USER_NAME), + Collections.singletonMap(createdBy ? "createdBy" : "userName", USER_NAME)); + } + + private Response getUserTokensResponse(TokenResource tokenResource, Subject caller, Map queryParams) { final MultivaluedMap queryParameters = new MultivaluedHashMap<>(); - queryParameters.put(createdBy ? "createdBy" : "userName", Arrays.asList(USER_NAME)); + queryParams.forEach((key, value) -> queryParameters.put(key, Arrays.asList(value))); final UriInfo uriInfo = EasyMock.createNiceMock(UriInfo.class); EasyMock.expect(uriInfo.getQueryParameters()).andReturn(queryParameters).anyTimes(); EasyMock.replay(uriInfo); - return tokenResource.getUserTokens(uriInfo); + return Subject.doAs(caller, (PrivilegedAction) () -> tokenResource.getUserTokens(uriInfo)); + } + + private TokenResource createTokenResourceWithTokensFor(String... users) throws Exception { + configureCommonExpectations(new HashMap<>(), Boolean.TRUE); + final TokenResource tr = new TokenResource(); + tr.request = request; + tr.context = context; + tr.init(); + for (String user : users) { + Subject.doAs(createTestSubject(user), (PrivilegedAction) () -> tr.doGet()); + } + return tr; + } + + @SuppressWarnings("unchecked") + private int tokenCount(Response response) { + final Collection tokens = ((Map>) JsonUtils.getObjectFromJsonString(response.getEntity().toString())) + .get("tokens"); + return tokens.size(); + } + + @Test + public void testGetUserTokensOwnerCanSeeOwnTokens() throws Exception { + final TokenResource tr = createTokenResourceWithTokensFor(USER_NAME); + final Response response = getUserTokensResponse(tr, createTestSubject(USER_NAME), Collections.singletonMap("userName", USER_NAME)); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(1, tokenCount(response)); + } + + @Test + public void testGetUserTokensUnauthorizedForOtherUser() throws Exception { + final TokenResource tr = createTokenResourceWithTokensFor(USER_NAME); + final Response response = getUserTokensResponse(tr, createTestSubject("bob"), Collections.singletonMap("userName", USER_NAME)); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertTrue(response.getEntity().toString().contains("not authorized")); + } + + @Test + public void testGetUserTokensUnauthorizedForUserNameOrCreatedByOfOtherUser() throws Exception { + final TokenResource tr = createTokenResourceWithTokensFor(USER_NAME); + final Response response = getUserTokensResponse(tr, createTestSubject("bob"), Collections.singletonMap("userNameOrCreatedBy", USER_NAME)); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertTrue(response.getEntity().toString().contains("not authorized")); + } + + @Test + public void testGetUserTokensAllTokensDeniedForOrdinaryUser() throws Exception { + final TokenResource tr = createTokenResourceWithTokensFor(USER_NAME); + final Response response = getUserTokensResponse(tr, createTestSubject("bob"), Collections.singletonMap("allTokens", "true")); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertTrue(response.getEntity().toString().contains("not authorized")); + } + + @Test + public void testGetUserTokensAdminCanSeeAllTokens() throws Exception { + final TokenResource tr = createTokenResourceWithTokensFor(USER_NAME, "bob"); + usersCanSeeAllTokens.add("admin"); + final Response response = getUserTokensResponse(tr, createTestSubject("admin"), Collections.singletonMap("allTokens", "true")); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(2, tokenCount(response)); + } + + @Test + public void testGetUserTokensAdminCanSeeOtherUsersTokens() throws Exception { + final TokenResource tr = createTokenResourceWithTokensFor(USER_NAME); + usersCanSeeAllTokens.add("admin"); + final Response response = getUserTokensResponse(tr, createTestSubject("admin"), Collections.singletonMap("userName", USER_NAME)); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(1, tokenCount(response)); } @Test @@ -1700,6 +1883,42 @@ private Response doTestTokenRevocation(final TokenRenewalTestConfigs configs, St return doTestTokenLifecyle(TokenLifecycleOperation.Revoke, configs.isTokenStateServerManaged(), null, configs.getRenewers(), configs.getGroupRenewers(), null, configs.getCaller(), impersonatedUser).getValue(); } + private Response doTestSetTokenEnabledFlag(final TokenRenewalTestConfigs configs, final boolean enable) throws Exception { + final TokenLifecycleOperation operation = enable ? TokenLifecycleOperation.Enable : TokenLifecycleOperation.Disable; + return doTestTokenLifecyle(operation, configs.isTokenStateServerManaged(), null, configs.getRenewers(), configs.getGroupRenewers(), null, configs.getCaller(), null).getValue(); + } + + /** + * Issues a token under an impersonating subject (so the persisted metadata has + * userName=impersonated user and createdBy=impersonator) and then attempts to flip its + * enabled flag as {@code caller}. Mirrors the impersonated-token creation flow in + * {@link #testCreateImpersonatedToken(boolean)}. + */ + private Response doTestImpersonatedTokenSetEnabledFlag(final Subject caller, final boolean enable) throws Exception { + final String impersonatedUser = "impersonatedUserName"; + final Map contextExpectations = new HashMap<>(); + contextExpectations.put("knox.token.exp.server-managed", Boolean.TRUE.toString()); + contextExpectations.put(TokenResource.QUERY_PARAMETER_DOAS, impersonatedUser); + contextExpectations.put(AuthFilterUtils.PROXYUSER_PREFIX + "." + USER_NAME + ".users", impersonatedUser); + contextExpectations.put(AuthFilterUtils.PROXYUSER_PREFIX + "." + USER_NAME + ".hosts", "*"); + contextExpectations.put(ContextAttributes.IMPERSONATION_ENABLED_ATTRIBUTE, Boolean.TRUE.toString()); + configureCommonExpectations(contextExpectations, Boolean.TRUE); + + final TokenResource tr = new TokenResource(); + tr.request = request; + tr.context = context; + tr.init(); + + final Subject issuer = createTestSubject(USER_NAME); + issuer.getPrincipals().add(new ImpersonatedPrincipal(impersonatedUser)); + final Response issueResponse = Subject.doAs(issuer, (PrivilegedAction) () -> tr.doGet()); + assertEquals(200, issueResponse.getStatus()); + final String accessToken = getTagValue(issueResponse.getEntity().toString(), "access_token"); + final String tokenId = TokenUtils.getTokenId(new JWTToken(accessToken)); + + return requestSetTokenEnabledFlag(tr, tokenId, enable, caller); + } + /** * @param operation A TokenLifecycleOperation * @param serviceLevelConfig true, if server-side token state management should be enabled at the service level; @@ -1751,6 +1970,8 @@ private Map.Entry doTestTokenLifecyle(final Tok Response response = switch (operation) { case Renew -> requestTokenRenewal(tr, accessToken, caller); case Revoke -> requestTokenRevocation(tr, accessToken, caller); + case Enable -> requestSetTokenEnabledFlag(tr, TokenUtils.getTokenId(new JWTToken(accessToken)), true, caller); + case Disable -> requestSetTokenEnabledFlag(tr, TokenUtils.getTokenId(new JWTToken(accessToken)), false, caller); }; return new AbstractMap.SimpleEntry<>(tss, response); @@ -1792,6 +2013,11 @@ private static Response requestTokenRevocation(final TokenResource tr, final Str return response; } + private static Response requestSetTokenEnabledFlag(final TokenResource tr, final String tokenId, final boolean enable, final Subject caller) { + final PrivilegedAction action = () -> enable ? tr.enable(tokenId) : tr.disable(tokenId); + return caller != null ? Subject.doAs(caller, action) : action.run(); + } + private static void validateSuccessfulRenewalResponse(final Response response) throws IOException { validateRenewalResponse(response, 200, true, null, null); } @@ -1801,13 +2027,22 @@ private static void validateRenewalResponse(final Response response, final boolean expectedResult, final String expectedMessage, final TokenResource.ErrorCode expectedCode) throws IOException { + validateLifecycleResponse(response, "renewed", expectedStatusCode, expectedResult, expectedMessage, expectedCode); + } + + private static void validateLifecycleResponse(final Response response, + final String resultField, + final int expectedStatusCode, + final boolean expectedResult, + final String expectedMessage, + final TokenResource.ErrorCode expectedCode) throws IOException { assertEquals(expectedStatusCode, response.getStatus()); assertTrue(response.hasEntity()); String responseContent = (String) response.getEntity(); assertNotNull(responseContent); assertFalse(responseContent.isEmpty()); Map json = parseJSONResponse(responseContent); - boolean result = Boolean.valueOf((String)json.get("renewed")); + boolean result = Boolean.parseBoolean((String) json.get(resultField)); assertEquals(expectedResult, result); assertEquals(expectedMessage, json.get("error")); if (expectedCode != null) { @@ -1824,18 +2059,21 @@ private static void validateRevocationResponse(final Response response, final boolean expectedResult, final String expectedMessage, final TokenResource.ErrorCode expectedCode) throws IOException { - assertEquals(expectedStatusCode, response.getStatus()); - assertTrue(response.hasEntity()); - String responseContent = (String) response.getEntity(); - assertNotNull(responseContent); - assertFalse(responseContent.isEmpty()); - Map json = parseJSONResponse(responseContent); - boolean result = Boolean.valueOf((String)json.get("revoked")); - assertEquals(expectedResult, result); - assertEquals(expectedMessage, json.get("error")); - if (expectedCode != null) { - assertEquals(expectedCode.toInt(), json.get("code")); - } + validateLifecycleResponse(response, "revoked", expectedStatusCode, expectedResult, expectedMessage, expectedCode); + } + + private static void validateSuccessfulSetEnabledFlagResponse(final Response response, final boolean enable) throws IOException { + validateSetEnabledFlagResponse(response, 200, true, null, null); + final Map json = parseJSONResponse((String) response.getEntity()); + assertEquals(String.valueOf(enable), json.get("isEnabled")); + } + + private static void validateSetEnabledFlagResponse(final Response response, + final int expectedStatusCode, + final boolean expectedResult, + final String expectedMessage, + final TokenResource.ErrorCode expectedCode) throws IOException { + validateLifecycleResponse(response, "setEnabledFlag", expectedStatusCode, expectedResult, expectedMessage, expectedCode); } @@ -2220,4 +2458,55 @@ public void testNoActClaimWithoutImpersonation() throws Exception { EasyMock.verify(request, context); } + + /** + * KNOX-3403: On a NON-server-managed topology, an impersonated request (traditional doAs or RFC 8693 + * token exchange, where the actor is the primary principal and the subject is the impersonated one) + * must still issue a token whose {@code sub} is the impersonated subject. Previously buildUserContext + * only applied the impersonated identity when {@code tokenStateService != null}, so on a non-managed + * topology the {@code sub} incorrectly fell back to the primary principal (the actor). + */ + @Test + @SuppressForbidden + public void testImpersonatedTokenSubjectOnNonServerManagedTopology() throws Exception { + final String primaryUser = "admin"; // primary principal (authenticated caller / actor) + final String impersonatedUser = "bob"; // impersonated subject (distinct from USER_NAME) + + // No serverManagedTssEnabled argument -> token state service is absent (non-server-managed). + configureCommonExpectations(createDelegatedAuthContextExpectations(true, true)); + Subject subject = createSubjectWithOptionalImpersonation(primaryUser, impersonatedUser); + JWTToken parsedToken = getTokenWithSubject(subject); + + // The token subject must be the impersonated user, not the primary. + assertEquals("Non-server-managed impersonated token must use the impersonated subject as sub", + impersonatedUser, parsedToken.getSubject()); + + // The 'act' claim still records the primary user (delegated auth enabled). + Object actClaim = parsedToken.getClaimAsObject(JWTToken.ACT_CLAIM); + assertNotNull("RFC 8693 'act' claim should be present", actClaim); + assertTrue("'act' claim should be a Map", actClaim instanceof Map); + @SuppressWarnings("unchecked") + Map actClaimMap = (Map) actClaim; + assertEquals("'act' claim should contain the primary user's subject", primaryUser, actClaimMap.get("sub")); + + EasyMock.verify(request, context); + } + + /** + * KNOX-3403: sanity check that decoupling the impersonated-sub from server-managed does not change + * the non-impersonating case - the token {@code sub} remains the authenticated (primary) user on a + * non-server-managed topology. + */ + @Test + @SuppressForbidden + public void testNonImpersonatedTokenSubjectOnNonServerManagedTopology() throws Exception { + configureCommonExpectations(createDelegatedAuthContextExpectations(true, false)); + Subject subject = createSubjectWithOptionalImpersonation(USER_NAME, null); + JWTToken parsedToken = getTokenWithSubject(subject); + + assertEquals(USER_NAME, parsedToken.getSubject()); + assertNull("'act' claim should NOT be present without impersonation", parsedToken.getClaimAsObject(JWTToken.ACT_CLAIM)); + + EasyMock.verify(request, context); + } } diff --git a/gateway-shell-release/home/bin/knoxshell.sh b/gateway-shell-release/home/bin/knoxshell.sh index 14cea81e79..8b0d708496 100755 --- a/gateway-shell-release/home/bin/knoxshell.sh +++ b/gateway-shell-release/home/bin/knoxshell.sh @@ -81,7 +81,7 @@ function main { checkJava buildAppJavaOpts - $JAVA "${APP_JAVA_OPTS[@]}" -Dlog4j.configurationFile=conf/knoxshell-log4j2.xml -javaagent:"$APP_BIN_DIR"/../lib/aspectjweaver.jar -cp "$APP_JAR":lib/* org.apache.knox.gateway.shell.Shell "$@" || exit 1 + $JAVA "${APP_JAVA_OPTS[@]}" -Dlog4j.configurationFile=conf/knoxshell-log4j2.xml -javaagent:"$APP_BIN_DIR"/../lib/aspectjweaver.jar -cp "$APP_JAR":lib/* org.apache.knox.gateway.launcher.Launcher "$@" || exit 1 return 0 } diff --git a/gateway-shell-release/home/conf/knoxshell-log4j2.xml b/gateway-shell-release/home/conf/knoxshell-log4j2.xml index c8ed33c6e6..275094a344 100644 --- a/gateway-shell-release/home/conf/knoxshell-log4j2.xml +++ b/gateway-shell-release/home/conf/knoxshell-log4j2.xml @@ -15,10 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. --> - - logs + ${sys:launcher.dir}/../logs ${sys:launcher.name}.log diff --git a/gateway-shell-release/pom.xml b/gateway-shell-release/pom.xml index e8b872b011..8322527a26 100644 --- a/gateway-shell-release/pom.xml +++ b/gateway-shell-release/pom.xml @@ -43,8 +43,12 @@ false + org.apache.knox.gateway.launcher.Launcher + + true + @@ -54,6 +58,7 @@ schema/** **/*.ldif + META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat diff --git a/gateway-shell/pom.xml b/gateway-shell/pom.xml index ac24cba33e..bb718e8c40 100644 --- a/gateway-shell/pom.xml +++ b/gateway-shell/pom.xml @@ -53,12 +53,6 @@ org.apache.groovy groovy-groovysh - - - jline - jline - - org.apache.groovy @@ -69,20 +63,32 @@ groovy-json - org.fusesource.jansi + org.apache.httpcomponents + httpcore + + + org.apache.httpcomponents + httpclient + + + org.jline jansi org.jline - jline + jline-builtins - org.apache.httpcomponents - httpcore + org.jline + jline-console - org.apache.httpcomponents - httpclient + org.jline + jline-reader + + + org.jline + jline-terminal net.minidev diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/KnoxShellCommandRegistry.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/KnoxShellCommandRegistry.java new file mode 100644 index 0000000000..a46c58007a --- /dev/null +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/KnoxShellCommandRegistry.java @@ -0,0 +1,103 @@ +/* + * 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.shell; +import org.jline.console.CommandRegistry; +import org.jline.console.CommandMethods; +import org.jline.console.CommandInput; +import org.jline.console.CmdDesc; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.reader.impl.completer.SystemCompleter; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class KnoxShellCommandRegistry implements CommandRegistry { + + private final Map commands; + private final Map aliases; + + public KnoxShellCommandRegistry(Map commands, Map aliases) { + this.commands = commands; + this.aliases = aliases != null ? aliases : Collections.emptyMap(); + } + + @Override + public boolean hasCommand(String command) { + return commands.containsKey(command) || aliases.containsKey(command); + } + + @Override + public Set commandNames() { + return commands.keySet(); + } + + @Override + public Map commandAliases() { + return aliases; + } + + @Override + public List commandInfo(String command) { + return Collections.emptyList(); + } + + @Override + public CmdDesc commandDescription(List args) { + return new CmdDesc(false); // Disables floating tooltip widgets for these commands + } + + @Override + public SystemCompleter compileCompleters() { + SystemCompleter out = new SystemCompleter(); + + // Add all our main commands to the JLine completion engine + for (String cmd : commands.keySet()) { + out.add(cmd, getCompletersForCommand(cmd)); + } + + // Tell JLine to wire up all shortcuts to the exact same completion logic + out.addAliases(aliases); + return out; + } + + @Override + public Object invoke(CommandSession session, String command, Object... args) throws Exception { + // Resolve shortcut to full command, or keep as-is + String actualCommand = aliases.getOrDefault(command, command); + CommandMethods methods = commands.get(actualCommand); + + if (methods != null && methods.execute() != null) { + CommandInput input = new CommandInput(command, args, session); + return methods.execute().apply(input); + } + return null; + } + + private List getCompletersForCommand(String command) { + String actualCommand = aliases.getOrDefault(command, command); + CommandMethods methods = commands.get(actualCommand); + + if (methods != null && methods.compileCompleter() != null) { + return methods.compileCompleter().apply(actualCommand); + } + return Collections.singletonList(NullCompleter.INSTANCE); + } +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/SafeCompleter.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/SafeCompleter.java new file mode 100644 index 0000000000..35e9b29097 --- /dev/null +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/SafeCompleter.java @@ -0,0 +1,49 @@ +/* + * 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.shell; + +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; + +import java.util.List; + +/** + * A wrapper to protect JLine from crashing when underlying completers + * (like Groovy's reflection completer) throw unexpected JVM exceptions. + */ +public class SafeCompleter implements Completer { + private final Completer delegate; + + public SafeCompleter(Completer delegate) { + this.delegate = delegate; + } + + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { + if (delegate == null) { + return; + } + try { + delegate.complete(reader, line, candidates); + } catch (Throwable t) { + // ignore + } + } +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/Shell.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/Shell.java index 2cd2192fc9..8b673ce683 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/Shell.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/Shell.java @@ -19,10 +19,16 @@ import groovy.ui.GroovyMain; +import org.apache.groovy.groovysh.jline.SystemRegistryImpl; +import org.apache.knox.gateway.shell.commands.AbstractKnoxShellCommand; import org.apache.knox.gateway.shell.commands.AbstractSQLCommandSupport; import org.apache.knox.gateway.shell.commands.CSVCommand; import org.apache.knox.gateway.shell.commands.DataSourceCommand; +import org.apache.knox.gateway.shell.commands.ImportCommand; +import org.apache.knox.gateway.shell.commands.LoadCommand; +import org.apache.knox.gateway.shell.commands.PurgeCommand; import org.apache.knox.gateway.shell.commands.SelectCommand; +import org.apache.knox.gateway.shell.commands.ShowCommand; import org.apache.knox.gateway.shell.commands.WebHDFSCommand; import org.apache.knox.gateway.shell.hbase.HBase; import org.apache.knox.gateway.shell.hdfs.Hdfs; @@ -31,19 +37,41 @@ import org.apache.knox.gateway.shell.table.KnoxShellTable; import org.apache.knox.gateway.shell.workflow.Workflow; import org.apache.knox.gateway.shell.yarn.Yarn; -import org.apache.groovy.groovysh.AnsiDetector; -import org.apache.groovy.groovysh.Groovysh; -import org.fusesource.jansi.Ansi; -import org.fusesource.jansi.AnsiConsole; +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.console.CommandMethods; +import org.jline.console.SystemRegistry; +import org.jline.reader.Completer; +import org.jline.reader.EndOfFileException; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.UserInterruptException; +import org.jline.reader.impl.DefaultParser; +import org.jline.reader.impl.completer.AggregateCompleter; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; public class Shell { private static final List NON_INTERACTIVE_COMMANDS = Arrays.asList("buildTrustStore", "init", "list", "destroy", "knoxline"); + private static final List EXIT_COMMANDS = Arrays.asList(":exit", ":x", ":quit", ":q"); + + private static final List HELP_COMMANDS = Arrays.asList(":help", ":h", "?"); + private static final String[] IMPORTS = new String[] { KnoxSession.class.getName(), HBase.class.getName(), @@ -56,47 +84,263 @@ public class Shell { KnoxShellTable.class.getName() }; - static { - AnsiConsole.systemInstall(); - Ansi.setDetector( new AnsiDetector() ); - System.setProperty( "groovysh.prompt", "knox" ); - } - - @SuppressWarnings("PMD.DoNotUseThreads") // we need to define a Thread to be able to register a shutdown hook - public static void main( String... args ) throws Exception { - if( args.length > 0 ) { + @SuppressWarnings("PMD.DoNotUseThreads") + public static void main(String... args) throws Exception { + if (args.length > 0) { if (NON_INTERACTIVE_COMMANDS.contains(args[0])) { - final String[] arguments = new String[args.length == 1 ? 1:3]; - arguments[0] = args[0]; - if (args.length > 1) { - arguments[1] = "--gateway"; - arguments[2] = args[1]; - } - KnoxSh.main(arguments); + final String[] arguments = new String[args.length == 1 ? 1 : 3]; + arguments[0] = args[0]; + if (args.length > 1) { + arguments[1] = "--gateway"; + arguments[2] = args[1]; + } + KnoxSh.main(arguments); } else { - GroovyMain.main( args ); + // Execute Groovy scripts headlessly + GroovyMain.main(args); } } else { - Groovysh shell = new Groovysh(); - Runtime.getRuntime().addShutdownHook(new Thread() { - @Override - public void run() { - System.out.println("Closing any open connections ..."); - AbstractSQLCommandSupport sqlcmd = (AbstractSQLCommandSupport) shell.getRegistry().getProperty(":ds"); - sqlcmd.closeConnections(); - sqlcmd = (AbstractSQLCommandSupport) shell.getRegistry().getProperty(":sql"); - sqlcmd.closeConnections(); + // Boot the Interactive JLine 3 REPL + new Shell().startInteractiveShell(); + } + } + + private void startInteractiveShell() throws Exception { + // 1. Build Terminal and Engine + Terminal terminal = TerminalBuilder.builder().system(true).name("KnoxShell").build(); + GroovyEngine engine = new GroovyEngine(); + + // 2. Pre-load Knox imports + for (String name : IMPORTS) { + engine.execute("import " + name); + } + + // 3. Instantiate and Map Custom Commands + List commands = createCommands(engine, terminal); + Map registry = createRegistry(commands); + + Map commandMethods = createCommandMethods(commands); + Map commandAliases = createCommandAliases(commands); + + DefaultParser parser = new DefaultParser(); + // Override default regex to allow '.' as a valid command string + // Original: "[:]?[a-zA-Z]+[a-zA-Z0-9_-]*" + parser.setRegexCommand("(?:\\.|[:]?[a-zA-Z]+[a-zA-Z0-9_-]*)"); + Path workDir = Paths.get(System.getProperty("user.dir")); + KnoxShellCommandRegistry knoxShellCommandRegistry = new KnoxShellCommandRegistry(commandMethods, commandAliases); + SystemRegistry systemRegistry = new SystemRegistryImpl(parser, terminal, () -> workDir, null); + systemRegistry.setCommandRegistries(knoxShellCommandRegistry); + SystemRegistry.add(systemRegistry); + + // 4. Setup Tab Completers for our custom commands (e.g., ":sql", ":fs") + Completer combinedCompleter = new AggregateCompleter( + systemRegistry.completer(), + new SafeCompleter(engine.getScriptCompleter())); + + // 5. Build the LineReader + LineReader reader = LineReaderBuilder.builder() + .parser(parser) + .terminal(terminal) + .completer(combinedCompleter) + .variable(LineReader.HISTORY_FILE, Paths.get(System.getProperty("user.home"), ".knoxshell_history")) + .build(); + + terminal.writer().println("Apache Knox Shell"); + terminal.writer().println("Type ':help' (':h' or '?') for help, ':exit' or ':quit' (':x' or ':q') to quit."); + terminal.writer().flush(); + + // 6. Setup Shutdown Hook (Calling closeConnections directly on our object instances) + createShutdownHook(commands); + + // 7. The REPL Loop + runRepl(reader, terminal, registry, engine); + } + + private List createCommands(GroovyEngine engine, Terminal terminal) { + return Arrays.asList( + new CSVCommand(engine, terminal), + new DataSourceCommand(engine, terminal), + new SelectCommand(engine, terminal), + new WebHDFSCommand(engine, terminal), + new ImportCommand(engine, terminal), + new LoadCommand(engine, terminal), + new PurgeCommand(engine, terminal), + new ShowCommand(engine, terminal) + ); + } + + private Map createRegistry(List commands) { + Map registry = new HashMap<>(); + if (commands == null || commands.isEmpty()) { + return registry; + } + + for (AbstractKnoxShellCommand cmd : commands) { + registerCommand(registry, cmd); + } + + return registry; + } + + private void createShutdownHook(List commands) { + if (commands == null || commands.isEmpty()) { + return; + } + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("\nClosing any open connections..."); + + for (AbstractKnoxShellCommand cmd : commands) { + // Check if the command inherits from the SQL base class + if (cmd instanceof AbstractSQLCommandSupport) { + ((AbstractSQLCommandSupport) cmd).closeConnections(); + } + } + })); + } + + private void registerCommand(Map registry, AbstractKnoxShellCommand cmd) { + registry.put(cmd.getName(), cmd); + if (cmd.getShortcut() != null && !cmd.getShortcut().isEmpty()) { + registry.put(cmd.getShortcut(), cmd); + } + } + + private Map createCommandMethods(List commands) { + Map commandMethods = new HashMap<>(); + + if (commands == null || commands.isEmpty()) { + return commandMethods; + } + + for (AbstractKnoxShellCommand cmd : commands) { + commandMethods.put(cmd.getName(), new CommandMethods( + (input) -> { + try { + String[] allTokens = input.args(); + // input.args() includes the command name, so we skip(1) to get the arguments + List argsList = (allTokens != null && allTokens.length > 1) + ? Arrays.stream(allTokens).skip(1).collect(Collectors.toList()) + : Collections.emptyList(); + + return cmd.execute(argsList); + } catch (Exception e) { + input.session().terminal().writer().println("Error: " + e.getMessage()); + return null; + } + }, + (line) -> { + List completers = cmd.getCompleters(); + return (completers != null && !completers.isEmpty()) + ? completers + : Collections.singletonList(NullCompleter.INSTANCE); + } + )); + } + + return commandMethods; + } + + private Map createCommandAliases(List commands) { + Map commandAliases = new HashMap<>(); + + if (commands == null || commands.isEmpty()) { + return commandAliases; + } + + for (AbstractKnoxShellCommand cmd : commands) { + String shortcut = cmd.getShortcut(); + if (shortcut != null && !shortcut.isEmpty()) { + commandAliases.put(shortcut, cmd.getName()); + } + } + + return commandAliases; + } + + private void runRepl(LineReader reader, Terminal terminal, Map registry, GroovyEngine engine) { + while (true) { + try { + String line = reader.readLine("knox> "); + if (line == null) { + return; } - }); - for( String name : IMPORTS ) { - shell.execute( "import " + name ); + + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + + // --- BUILT-IN COMMANDS --- + if (EXIT_COMMANDS.stream().anyMatch(trimmed::equalsIgnoreCase)) { + return; // Exits the method, allowing main() to finish cleanly + } + + if (HELP_COMMANDS.stream().anyMatch(h -> trimmed.equalsIgnoreCase(h) || trimmed.startsWith(h + " "))) { + List helpParts = reader.getParser().parse(trimmed, 0).words(); + + if (helpParts.size() > 1) { + // Detailed help for a specific command (e.g., ":help :fs") + String targetCmd = helpParts.get(1); + if (registry.containsKey(targetCmd)) { + AbstractKnoxShellCommand cmd = registry.get(targetCmd); + terminal.writer().println(cmd.getDescription()); + terminal.writer().println(cmd.getHelp()); + } else { + terminal.writer().println("Unknown command: " + targetCmd); + } + } else { + // General help menu + terminal.writer().println("Available Custom Knox Commands:"); + + // Use a Stream to get distinct commands (ignores duplicate alias keys) + registry.values().stream().distinct().forEach(cmd -> { + String names = cmd.getName() + (cmd.getShortcut() != null ? ", " + cmd.getShortcut() : ""); + String desc = cmd.getDescription() != null ? cmd.getDescription() : ""; + terminal.writer().printf(Locale.ROOT, " %-25s %s%n", names, desc); + }); + + terminal.writer().println(); + terminal.writer().printf(Locale.ROOT, " %-25s %s%n", ":help, :h, ?", "Displays this help message or specific command usage"); + terminal.writer().printf(Locale.ROOT, " %-25s %s%n", ":exit, :x, :quit, :q", "Exits the shell"); + terminal.writer().println("\nNote: Any other input is evaluated natively as Groovy code."); + } + terminal.writer().flush(); + continue; // Skip the rest of the loop + } + + // Route custom Knox commands + List parts = reader.getParser().parse(trimmed, 0).words(); + String commandName = parts.get(0); + + if (registry.containsKey(commandName)) { + AbstractKnoxShellCommand cmd = registry.get(commandName); + + // Extract arguments to pass to the command (quotes stripped by parser) + List cmdArgs = parts.size() > 1 ? new ArrayList<>(parts.subList(1, parts.size())) : new ArrayList<>(); + + Object res = cmd.execute(cmdArgs); + if (res != null) { + terminal.writer().println(res); + } + } else { + // Fallback to evaluating standard Groovy script logic + Object result = engine.execute(line); + if (result != null) { + terminal.writer().println("==> " + result); + } + } + + terminal.writer().flush(); + + } catch (UserInterruptException e) { + continue; // Ctrl+C: discard current line, return to prompt + } catch (EndOfFileException e) { + return; // Ctrl+D: exit + } catch (Throwable e) { + // Shell should not exit (similar to legacy GroovySh) + terminal.writer().println("Error: " + e.getMessage()); + terminal.writer().flush(); } - // register custom groovysh commands - shell.register(new SelectCommand(shell)); - shell.register(new DataSourceCommand(shell)); - shell.register(new CSVCommand(shell)); - shell.register(new WebHDFSCommand(shell)); - shell.run( null ); } } diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractKnoxShellCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractKnoxShellCommand.java index f2bca42957..dcd8025517 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractKnoxShellCommand.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractKnoxShellCommand.java @@ -17,47 +17,64 @@ */ package org.apache.knox.gateway.shell.commands; +import java.util.Collections; import java.util.List; +import org.apache.groovy.groovysh.jline.GroovyEngine; import org.apache.knox.gateway.shell.CredentialCollectionException; import org.apache.knox.gateway.shell.CredentialCollector; -import org.apache.groovy.groovysh.CommandSupport; -import org.apache.groovy.groovysh.Groovysh; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.terminal.Terminal; + +public abstract class AbstractKnoxShellCommand { + + protected final GroovyEngine engine; + protected final Terminal terminal; + private final String name; + private final String shortcut; -public abstract class AbstractKnoxShellCommand extends CommandSupport { - static final String KNOXSQLHISTORY = "__knoxsqlhistory"; - protected static final String KNOXDATASOURCES = "__knoxdatasources"; private String description; private String usage; private String help; - public AbstractKnoxShellCommand(Groovysh shell, String name, String shortcut) { - super(shell, name, shortcut); - } - - public AbstractKnoxShellCommand(Groovysh shell, String name, String shortcut, - String desc, String usage, String help) { - super(shell, name, shortcut); + public AbstractKnoxShellCommand(GroovyEngine engine, Terminal terminal, String name, String shortcut, + String desc, String usage, String help) { + this.engine = engine; + this.terminal = terminal; + this.name = name; + this.shortcut = shortcut; this.description = desc; this.usage = usage; this.help = help; } - @Override + public String getName() { + return name; + } + + public String getShortcut() { + return shortcut; + } + public String getDescription() { - return description; + return description; } - @Override public String getUsage() { return usage; } - @Override public String getHelp() { return help; } + public List getCompleters() { + return Collections.singletonList(NullCompleter.INSTANCE); + } + + public abstract Object execute(List args) throws Exception; + protected String getBindingVariableNameForResultingTable(List args) { String variableName = null; boolean nextOne = false; @@ -76,6 +93,9 @@ protected String getBindingVariableNameForResultingTable(List args) { protected CredentialCollector login() throws CredentialCollectionException { KnoxLoginDialog dlg = new KnoxLoginDialog(); dlg.collect(); + if (!dlg.ok) { + throw new CredentialCollectionException("Login cancelled by user."); + } return dlg; } -} \ No newline at end of file +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractSQLCommandSupport.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractSQLCommandSupport.java index 50eaf3abcd..e7b43f8b55 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractSQLCommandSupport.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/AbstractSQLCommandSupport.java @@ -28,79 +28,79 @@ import org.apache.knox.gateway.shell.KnoxDataSource; import org.apache.knox.gateway.shell.KnoxSession; import org.apache.knox.gateway.shell.jdbc.JDBCUtils; -import org.apache.groovy.groovysh.Groovysh; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.terminal.Terminal; public abstract class AbstractSQLCommandSupport extends AbstractKnoxShellCommand { + protected static final String KNOXDATASOURCES = "__knoxdatasources"; protected static final String KNOXDATASOURCE = "__knoxdatasource"; - private static final Object KNOXDATASOURCE_CONNECTIONS = "__knoxdatasourceconnections"; - - public AbstractSQLCommandSupport(Groovysh shell, String name, String shortcut) { - super(shell, name, shortcut); - } + private static final String KNOXSQLHISTORY = "__knoxsqlhistory"; + private static final String KNOXDATASOURCE_CONNECTIONS = "__knoxdatasourceconnections"; - public AbstractSQLCommandSupport(Groovysh shell, String name, String shortcut, String desc, String usage, - String help) { - super(shell, name, shortcut, desc, usage, help); + public AbstractSQLCommandSupport(GroovyEngine engine, Terminal terminal, String name, String shortcut, String desc, String usage, + String help) { + super(engine, terminal, name, shortcut, desc, usage, help); } @SuppressWarnings("unchecked") protected Connection getConnectionFromSession(KnoxDataSource ds) { - HashMap connections = - (HashMap) getVariables() - .getOrDefault(KNOXDATASOURCE_CONNECTIONS, - new HashMap()); - - Connection conn = connections.get(ds.getName()); - return conn; + //GroovyEngine bindings lack getOrDefault, so we check for null manually + HashMap connections = (HashMap) engine.get(KNOXDATASOURCE_CONNECTIONS); + if (connections == null) { + connections = new HashMap<>(); + } + return connections.get(ds.getName()); } @SuppressWarnings("unchecked") - protected Connection getConnection(KnoxDataSource ds, String user, String pass) throws SQLException, Exception { + protected Connection getConnection(KnoxDataSource ds, String user, String pass) throws SQLException { Connection conn = getConnectionFromSession(ds); if (conn == null) { if (user != null && pass != null) { conn = JDBCUtils.createConnection(ds.getConnectStr(), user, pass); - } - else { + } else { conn = JDBCUtils.createConnection(ds.getConnectStr(), null, null); + } + HashMap connections = (HashMap) engine.get(KNOXDATASOURCE_CONNECTIONS); + if (connections == null) { + connections = new HashMap<>(); } - HashMap connections = - (HashMap) getVariables() - .getOrDefault(KNOXDATASOURCE_CONNECTIONS, - new HashMap()); connections.put(ds.getName(), conn); - getVariables().put(KNOXDATASOURCE_CONNECTIONS, connections); + engine.put(KNOXDATASOURCE_CONNECTIONS, connections); } return conn; } + @SuppressWarnings("unchecked") protected void persistSQLHistory() { - Map> sqlHistories = - (Map>) getVariables().get(KNOXSQLHISTORY); + Map> sqlHistories = (Map>) engine.get(KNOXSQLHISTORY); KnoxSession.persistSQLHistory(sqlHistories); } + @SuppressWarnings("unchecked") protected void persistDataSources() { - Map datasources = - (Map) getVariables().get(KNOXDATASOURCES); + Map datasources = (Map) engine.get(KNOXDATASOURCES); KnoxSession.persistDataSources(datasources); } + @SuppressWarnings("unchecked") protected List getSQLHistory(String dataSourceName) { List sqlHistory = null; - Map> sqlHistories = - (Map>) getVariables().get(KNOXSQLHISTORY); + Map> sqlHistories = (Map>) engine.get(KNOXSQLHISTORY); + if (sqlHistories == null) { // check for persisted histories for known datasources sqlHistories = loadSQLHistories(); if (sqlHistories == null || sqlHistories.isEmpty()) { sqlHistories = new HashMap<>(); - getVariables().put(KNOXSQLHISTORY, sqlHistories); + engine.put(KNOXSQLHISTORY, sqlHistories); } } + // get the history for the specific datasource sqlHistory = sqlHistories.get(dataSourceName); if (sqlHistory == null) { @@ -120,10 +120,13 @@ private Map> loadSQLHistories() { try { sqlHistories = KnoxSession.loadSQLHistories(); if (sqlHistories != null) { - getVariables().put(KNOXSQLHISTORY, sqlHistories); + engine.put(KNOXSQLHISTORY, sqlHistories); } } catch (IOException e) { - e.printStackTrace(); + // Route errors through JLine terminal + terminal.writer().println("Error loading SQL history: " + e.getMessage()); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } return sqlHistories; } @@ -133,10 +136,13 @@ private Map loadDataSources() { try { datasources = KnoxSession.loadDataSources(); if (datasources != null) { - getVariables().put(KNOXDATASOURCES, datasources); + engine.put(KNOXDATASOURCES, datasources); } } catch (IOException e) { - e.printStackTrace(); + //Route errors through JLine terminal + terminal.writer().println("Error loading Data Sources: " + e.getMessage()); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } return datasources; } @@ -167,21 +173,23 @@ protected void addToSQLHistory(List sqlHistory, String sql) { persistSQLHistory(); } + @SuppressWarnings("unchecked") protected void removeFromSQLHistory(String dsName) { - Map> sqlHistories = - (Map>) getVariables().get(KNOXSQLHISTORY); - sqlHistories.remove(dsName); - persistSQLHistory(); + Map> sqlHistories = (Map>) engine.get(KNOXSQLHISTORY); + if (sqlHistories != null) { + sqlHistories.remove(dsName); + persistSQLHistory(); + } } + @SuppressWarnings("unchecked") protected Map getDataSources() { - Map datasources = (Map) getVariables().get(KNOXDATASOURCES); + Map datasources = (Map) engine.get(KNOXDATASOURCES); if (datasources == null) { datasources = loadDataSources(); if (datasources != null) { - getVariables().put(KNOXDATASOURCES, datasources); - } - else { + engine.put(KNOXDATASOURCES, datasources); + } else { datasources = new HashMap<>(); } } @@ -191,18 +199,18 @@ protected Map getDataSources() { @SuppressWarnings("unchecked") public void closeConnections() { // close all JDBC connections in the session - called by shutdown hook - HashMap connections = - (HashMap) getVariables() - .getOrDefault(KNOXDATASOURCE_CONNECTIONS, - new HashMap()); - connections.values().forEach(connection->{ - try { - if (!connection.isClosed()) { - connection.close(); + HashMap connections = (HashMap) engine.get((String) KNOXDATASOURCE_CONNECTIONS); + if (connections == null) { + connections = new HashMap<>(); + } + connections.values().forEach(connection -> { + try { + if (!connection.isClosed()) { + connection.close(); + } + } catch (SQLException e) { + // nop } - } catch (SQLException e) { - // nop - } - }); + }); } -} \ No newline at end of file +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/CSVCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/CSVCommand.java index 3c1bf73dfb..5d69fb1ae3 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/CSVCommand.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/CSVCommand.java @@ -20,32 +20,43 @@ import java.io.IOException; import java.util.List; +import org.apache.groovy.groovysh.jline.GroovyEngine; import org.apache.knox.gateway.shell.table.KnoxShellTable; -import org.apache.groovy.groovysh.Groovysh; + +import org.jline.terminal.Terminal; public class CSVCommand extends AbstractKnoxShellCommand { private static final String USAGE = ":csv [withHeaders] file-url||$variable-name [assign resulting-variable-name]"; private static final String DESC = "Build table from CSV file located at provided URL or KnoxShell $variable-name"; - private boolean withHeaders; - private String url; - public CSVCommand(Groovysh shell) { - super(shell, ":CSV", ":csv", DESC, USAGE, DESC); + public CSVCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, ":CSV", ":csv", DESC, USAGE, USAGE); } - @SuppressWarnings("unchecked") @Override public Object execute(List args) { - KnoxShellTable table = null; - String bindVariableName = null; - if (!args.isEmpty()) { - bindVariableName = getBindingVariableNameForResultingTable(args); + if (args == null || args.isEmpty()) { + terminal.writer().println("Usage: " + USAGE); + terminal.writer().flush(); + return null; } - if (args.get(0).contentEquals("withHeaders")) { + + KnoxShellTable table = null; + String bindVariableName = getBindingVariableNameForResultingTable(args); + + boolean withHeaders = false; + String url; + + if ("withHeaders".equalsIgnoreCase(args.get(0))) { withHeaders = true; - url = args.get(1); - } - else { + if (args.size() > 1) { + url = args.get(1); + } else { + terminal.writer().println("Error: Missing file URL or variable name."); + terminal.writer().flush(); + return null; + } + } else { url = args.get(0); } @@ -53,30 +64,32 @@ public Object execute(List args) { if (withHeaders) { if (url.startsWith("$")) { // a knoxshell variable is a csv file as a string - String csvString = (String) getVariables().get(url.substring(1)); + String csvString = (String) engine.get(url.substring(1)); table = KnoxShellTable.builder().csv().withHeaders().string(csvString); - } - else { + } else { table = KnoxShellTable.builder().csv().withHeaders().url(url); } - } - else { + } else { if (url.startsWith("$")) { // a knoxshell variable is a csv file as a string - String csvString = (String) getVariables().get(url.substring(1)); + String csvString = (String) engine.get(url.substring(1)); table = KnoxShellTable.builder().csv().string(csvString); - } - else { + } else { table = KnoxShellTable.builder().csv().url(url); } } } catch (IOException e) { - e.printStackTrace(); + terminal.writer().println("Error parsing CSV: " + e.getMessage()); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } + if (table != null && bindVariableName != null) { - getVariables().put(bindVariableName, table); + engine.put(bindVariableName, table); + terminal.writer().println("Assigned resulting table to variable: " + bindVariableName); + terminal.writer().flush(); } + return table; } - } diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/DataSourceCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/DataSourceCommand.java index bd01ae9cc8..8a4fe651c4 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/DataSourceCommand.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/DataSourceCommand.java @@ -20,68 +20,92 @@ import java.sql.Connection; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.knox.gateway.shell.CredentialCollectionException; import org.apache.knox.gateway.shell.CredentialCollector; import org.apache.knox.gateway.shell.KnoxDataSource; import org.apache.knox.gateway.shell.table.KnoxShellTable; -import org.apache.groovy.groovysh.Groovysh; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.ArgumentCompleter; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; public class DataSourceCommand extends AbstractSQLCommandSupport { - private static final String USAGE = ":ds (add|remove|select) [ds-name, connection-str, driver classname, authntype(none|basic)]"; + private static final String USAGE = ":ds (add|remove|list|select) [ds-name] [connection-str] [driver-classname] [authntype(none|basic)]"; private static final String DESC = "Datasource management commands. Persisted datasources maintain connection details across sessions"; - public DataSourceCommand(Groovysh shell) { - super(shell, ":datasources", ":ds", DESC, USAGE, DESC); + public DataSourceCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, ":datasources", ":ds", DESC, USAGE, DESC); } - @SuppressWarnings({"unchecked", "PMD.CloseResource"}) + @SuppressWarnings({"PMD.CloseResource"}) @Override public Object execute(List args) { - Map dataSources = - getDataSources(); - if (args.isEmpty()) { - args.add("list"); - } - if (args.get(0).equalsIgnoreCase("add")) { - KnoxDataSource ds = new KnoxDataSource(args.get(1), - args.get(2), - args.get(3), - args.get(4)); + Map dataSources = getDataSources(); + + String action = (args == null || args.isEmpty()) ? "list" : args.get(0); + + if ("add".equalsIgnoreCase(action)) { + if (args.size() < 5) { + terminal.writer().println("Error: Missing arguments for 'add'."); + terminal.writer().println("Usage: :ds add ds-name connection-str driver-classname authntype"); + terminal.writer().flush(); + return null; + } + KnoxDataSource ds = new KnoxDataSource(args.get(1), args.get(2), args.get(3), args.get(4)); dataSources.put(ds.getName(), ds); - getVariables().put(KNOXDATASOURCES, dataSources); + engine.put(KNOXDATASOURCES, dataSources); persistDataSources(); } - else if (args.get(0).equalsIgnoreCase("remove")) { + else if ("remove".equalsIgnoreCase(action)) { if (dataSources == null || dataSources.isEmpty()) { return "No datasources to remove."; } + if (args.size() < 2) { + terminal.writer().println("Error: Missing datasource name to remove."); + terminal.writer().flush(); + return null; + } + + String dsName = args.get(1); // if the removed datasource is currently selected, unselect it - dataSources.remove(args.get(1)); - if (getVariables().get(KNOXDATASOURCE) != null) { - if (args.get(1) != null) { - if (((String)getVariables().get(KNOXDATASOURCE)).equals(args.get(1))) { - System.out.println("unselecting datasource."); - getVariables().put(KNOXDATASOURCE, ""); - } - } - else { - System.out.println("Missing datasource name to remove."); + dataSources.remove(dsName); + + if (engine.get(KNOXDATASOURCE) != null) { + if ((engine.get(KNOXDATASOURCE)).equals(dsName)) { + terminal.writer().println("Unselecting datasource."); + terminal.writer().flush(); + engine.put(KNOXDATASOURCE, ""); } } - getVariables().put(KNOXDATASOURCES, dataSources); + engine.put(KNOXDATASOURCES, dataSources); persistDataSources(); - } - else if (args.get(0).equalsIgnoreCase("list")) { + } else if ("list".equalsIgnoreCase(action)) { // valid command no additional work needed though - } - else if(args.get(0).equalsIgnoreCase("select")) { + } else if ("select".equalsIgnoreCase(action)) { if (dataSources == null || dataSources.isEmpty()) { return "No datasources to select from."; } + if (args.size() < 2) { + terminal.writer().println("Error: Missing datasource name to select."); + terminal.writer().flush(); + return null; + } + KnoxDataSource dsValue = dataSources.get(args.get(1)); + if (dsValue == null) { + return "Error: Datasource '" + args.get(1) + "' not found."; + } + Connection conn = getConnectionFromSession(dsValue); try { if (conn == null || conn.isClosed()) { @@ -92,33 +116,40 @@ else if(args.get(0).equalsIgnoreCase("select")) { try { dlg = login(); } catch (CredentialCollectionException e) { - e.printStackTrace(); - return "Error: Credential collection failure."; + terminal.writer().println("Error: Credential collection failure."); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return null; } username = dlg.name(); pass = dlg.chars(); } try { - getConnection(dsValue, username, new String(pass)); + String passStr = (pass == null) ? null : new String(pass); + getConnection(dsValue, username, passStr); } catch (Exception e) { - e.printStackTrace(); - return "Error: Connection creation failure."; + terminal.writer().println("Error: Connection creation failure."); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return null; } } } catch (SQLException e) { - e.printStackTrace(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } + if (dataSources.containsKey(args.get(1))) { - getVariables().put(KNOXDATASOURCE, args.get(1)); + engine.put(KNOXDATASOURCE, args.get(1)); } + KnoxShellTable datasource = new KnoxShellTable(); datasource.title("Knox DataSource Selected"); datasource.header("Name").header("Connect String").header("Driver").header("Authn Type"); datasource.row().value(dsValue.getName()).value(dsValue.getConnectStr()).value(dsValue.getDriver()).value(dsValue.getAuthnType()); return datasource; - } - else { - return "ERROR: unknown datasources command."; + } else { + return "ERROR: unknown datasources command: " + action; } return buildTable(); @@ -128,20 +159,87 @@ private KnoxShellTable buildTable() { KnoxShellTable datasource = new KnoxShellTable(); datasource.title("Knox DataSources"); datasource.header("Name").header("Connect String").header("Driver").header("Authn Type"); + @SuppressWarnings("unchecked") Map dataSources = - (Map) getVariables().get(KNOXDATASOURCES); + (Map) engine.get(KNOXDATASOURCES); + if (dataSources != null && !dataSources.isEmpty()) { - for(KnoxDataSource dsValue : dataSources.values()) { + for (KnoxDataSource dsValue : dataSources.values()) { datasource.row().value(dsValue.getName()).value(dsValue.getConnectStr()).value(dsValue.getDriver()).value(dsValue.getAuthnType()); } } return datasource; } + @Override + public List getCompleters() { + + // Index 0: The command name itself (e.g., :ds). + // Because Shell.java routes this blindly, we just need a dummy placeholder + // so ArgumentCompleter correctly shifts the subcommands to Index 1. + Completer commandPlaceholder = (reader, parsedLine, candidates) -> {}; + + // Index 1: Subcommands + Completer subCommandCompleter = new StringsCompleter("add", "remove", "select", "list"); + + // Index 2: Dynamic Data Source Names + Completer nameCompleter = dataSourceNameCompleter(); + + ArgumentCompleter argCompleter = new ArgumentCompleter( + commandPlaceholder, + subCommandCompleter, + nameCompleter, + NullCompleter.INSTANCE // Stops suggesting after the DB name + ); + + // Return as a singleton list so Shell.java can just blindly grab it + return Collections.singletonList(argCompleter); + } + + private Completer dataSourceNameCompleter() { + return (reader, parsedLine, candidates) -> { + List words = parsedLine.words(); + // Safety guard against JLine background scans + if (words.size() > 1) { + String subCommand = words.get(1); + if ("select".equalsIgnoreCase(subCommand) || "remove".equalsIgnoreCase(subCommand)) { + List activeDataSources = getDataSourcesNames(); // Your method + for (String dsName : activeDataSources) { + candidates.add(new Candidate(dsName)); + } + } + } + }; + } + + + private List getDataSourcesNames() { + Map dataSources = getDataSources(); + if (dataSources == null || dataSources.isEmpty()) { + return Collections.emptyList(); + } else { + return dataSources.values() + .stream() + .map(KnoxDataSource::getName) + .collect(Collectors.toList()); + } + } + public static void main(String[] args) { - DataSourceCommand cmd = new DataSourceCommand(new Groovysh()); - List args2 = new ArrayList<>(); - cmd.execute(args2); + try { + Terminal terminal = TerminalBuilder.builder().system(true).build(); + GroovyEngine engine = new GroovyEngine(); + DataSourceCommand cmd = new DataSourceCommand(engine, terminal); + + List args2 = new ArrayList<>(); + Object res = cmd.execute(args2); + if (res != null) { + terminal.writer().println(res); + terminal.writer().flush(); + } + } catch (Exception e) { + e.printStackTrace(); + } } } diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/ImportCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/ImportCommand.java new file mode 100644 index 0000000000..a0145af3a5 --- /dev/null +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/ImportCommand.java @@ -0,0 +1,93 @@ +/* + * 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.shell.commands; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.terminal.Terminal; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Manages Groovy imports in the current shell session. + *

+ * Usage: + * :import - lists active imports + * :import org.apache.knox.gateway.shell.KnoxSession - adds a single import + * :import org.apache.knox.gateway.shell.* - wildcard import + */ +public class ImportCommand extends AbstractKnoxShellCommand { + + private static final String NAME = ":import"; + private static final String SHORTCUT = ":i"; + private static final String DESC = "Import a class into the namespace"; + private static final String USAGE = "Usage: :import []\n" + + " :import - list active imports\n" + + " :import - add a new import\n" + + " :import static - add a static import\n" + + " :import .* - wildcard import"; + private static final String HELP = USAGE; + + // Groovysh 4.x validation: chars, digits, underscore, dot, star, optional semicolon + private static final Pattern IMPORTED_ITEM_PATTERN = Pattern.compile("^[a-zA-Z0-9_. *]+;?$"); + + + public ImportCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, NAME, SHORTCUT, DESC, USAGE, HELP); + } + + @Override + public Object execute(List args) { + if (args == null || args.isEmpty()) { + // List mode + Map imports = engine.getImports(); + if (imports.isEmpty()) { + terminal.writer().println("No imports registered."); + } else { + terminal.writer().println("Active imports:"); + imports.values().stream() + .sorted() + .forEach(value -> terminal.writer().println(value)); } + terminal.writer().flush(); + return null; + } + + // Join with spaces to preserve "static" keyword + String target = String.join(" ", args).trim(); + + if (!IMPORTED_ITEM_PATTERN.matcher(target).matches()) { + terminal.writer().println("Invalid import definition: '" + target + "'"); + terminal.writer().flush(); + return null; + } + + // Strip Java-style semicolons + target = target.replace(";", ""); + + try { + engine.execute("import " + target); + terminal.writer().println("==> import " + target); + } catch (Exception e) { + terminal.writer().println("Failed to import '" + target + "': " + e.getMessage()); + } + + terminal.writer().flush(); + return null; + } +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/KnoxLoginDialog.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/KnoxLoginDialog.java index 90169e0964..1f24a855bc 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/KnoxLoginDialog.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/KnoxLoginDialog.java @@ -62,39 +62,13 @@ public void collect() throws CredentialCollectionException { } } - @Override - public String string() { - return new String(pass); - } - - @Override - public char[] chars() { - return pass; - } - - @Override - public byte[] bytes() { - return null; - } - - @Override - public String type() { - return "dialog"; - } - - @Override - public String name() { - return username; - } - - @Override - public void setPrompt(String prompt) { - } - - @Override - public void setName(String name) { - this.name = name; - } + @Override public String string() { return new String(pass); } + @Override public char[] chars() { return pass; } + @Override public byte[] bytes() { return null; } + @Override public String type() { return "dialog"; } + @Override public String name() { return username; } + @Override public void setPrompt(String prompt) {} + @Override public void setName(String name) { this.name = name; } public static void main(String[] args) { KnoxLoginDialog dlg = new KnoxLoginDialog(); diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoadCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoadCommand.java new file mode 100644 index 0000000000..f621d32852 --- /dev/null +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoadCommand.java @@ -0,0 +1,203 @@ +/* + * 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.shell.commands; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.builtins.Completers; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.terminal.Terminal; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; + +/** + * Loads a Groovy script file or URL into the shell and executes it. + * Matches the old Groovysh :load command behavior. + *

+ * Usage: + * :load /path/to/script.groovy + * :load ~/scripts/setup.groovy + * :load https://example.com/script.groovy + * . /path/to/script.groovy (alias) + */ +public class LoadCommand extends AbstractKnoxShellCommand { + + private static final String NAME = ":load"; + private static final String SHORTCUT = "."; + private static final String DESC = "Load a file or URL into the buffer"; + private static final String USAGE = "Usage: :load \n" + + " :load /path/to/script.groovy\n" + + " :load ~/scripts/setup.groovy\n" + + " :load https://example.com/script.groovy\n" + + " . /path/to/script.groovy"; + private static final String HELP = USAGE; + + public LoadCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, NAME, SHORTCUT, DESC, USAGE, HELP); + } + + @Override + public Object execute(List args) throws Exception { + if (args == null || args.isEmpty()) { + terminal.writer().println(USAGE); + terminal.writer().flush(); + return null; + } + + Object lastResult = null; + + // Iterate over arguments to support multi-file loading + // (e.g., :load file1.groovy file2.groovy) + for (String location : args) { + String script; + try { + script = readScript(location); + } catch (Exception e) { + terminal.writer().println("Failed to load '" + location + "': " + e.getMessage()); + terminal.writer().flush(); + continue; // Skip to the next file instead of aborting the whole command + } + + // Legacy feature: strip Unix shebangs (#!/usr/bin/env groovy) + if (script.startsWith("#!")) { + int newlineIndex = script.indexOf('\n'); + if (newlineIndex != -1) { + script = script.substring(newlineIndex + 1); + } else { + script = ""; + } + } + + if (script.trim().isEmpty()) { + terminal.writer().println("Warning: '" + location + "' is empty, nothing to execute."); + terminal.writer().flush(); + continue; + } + + terminal.writer().println("Loading " + location + " ..."); + terminal.writer().flush(); + + try { + lastResult = engine.execute(script); + if (lastResult != null) { + terminal.writer().println("==> " + lastResult); + terminal.writer().flush(); + } + } catch (Exception e) { + terminal.writer().println("Error executing script '" + location + "': " + e.getMessage()); + terminal.writer().flush(); + } + } + + return lastResult; + } + + private String readScript(String location) throws IOException { + // Try as URL first (http://, https://, file://) + if (isUrl(location)) { + return readFromUrl(location); + } + + // Expand ~ to user home + if (location.startsWith("~")) { + location = System.getProperty("user.home") + location.substring(1); + } + + Path path = Paths.get(location); + if (!Files.exists(path)) { + throw new IOException("File not found: " + path.toAbsolutePath()); + } + if (!Files.isReadable(path)) { + throw new IOException("File is not readable: " + path.toAbsolutePath()); + } + if (Files.isDirectory(path)) { + throw new IOException("Path is a directory, not a file: " + path.toAbsolutePath()); + } + + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + return readAndSkipShebang(reader); + } + } + + private boolean isUrl(String location) { + return location!=null && + (location.startsWith("http://") + || location.startsWith("https://") + || location.startsWith("file://")); + } + + private String readFromUrl(String urlStr) throws IOException { + URL url; + try { + url = new URL(urlStr); + } catch (MalformedURLException e) { + throw new IOException("Invalid URL: " + urlStr, e); + } + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { + return readAndSkipShebang(reader); + } + } + + private String readAndSkipShebang(BufferedReader reader) throws IOException { + String firstLine = reader.readLine(); + if (firstLine == null) { + return ""; + } + + StringBuilder scriptBuilder = new StringBuilder(); + + // If it's not a shebang, preserve the first line + if (!firstLine.startsWith("#!")) { + scriptBuilder.append(firstLine).append(System.lineSeparator()); + } + + // Read the rest of the file + String line; + while ((line = reader.readLine()) != null) { + scriptBuilder.append(line).append(System.lineSeparator()); + } + + return scriptBuilder.toString(); + } + + @Override + public List getCompleters() { + Completers.FileNameCompleter fileNameCompleter = new Completers.FileNameCompleter(); + Completer fileCompleter = (reader, parsedLine, candidates) -> { + String word = parsedLine.word(); + if (isUrl(word)) { + return; + } + fileNameCompleter.complete(reader, parsedLine, candidates); + }; + return Arrays.asList(fileCompleter, NullCompleter.INSTANCE); + } + +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoginCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoginCommand.java index e82e72d68c..c6135036a4 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoginCommand.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/LoginCommand.java @@ -23,36 +23,64 @@ import org.apache.knox.gateway.shell.CredentialCollectionException; import org.apache.knox.gateway.shell.KnoxSession; -import org.apache.groovy.groovysh.CommandSupport; -import org.apache.groovy.groovysh.Groovysh; -public class LoginCommand extends CommandSupport { +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; - public LoginCommand(Groovysh shell) { - super(shell, ":login", ":lgn"); +public class LoginCommand extends AbstractKnoxShellCommand { + + public LoginCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, ":login", ":lgn", + "Establishes a Knox session", + "Usage: :login ", + "Establishes a Knox session using terminal credentials"); } - @SuppressWarnings("unchecked") @Override public Object execute(List args) { + if (args == null || args.isEmpty()) { + terminal.writer().println("Error: Knox Gateway URL required."); + terminal.writer().println(getUsage()); + terminal.writer().flush(); + return null; + } + + String url = args.get(0); KnoxSession session = null; - KnoxLoginDialog dlg = new KnoxLoginDialog(); + try { + KnoxLoginDialog dlg = new KnoxLoginDialog(); dlg.collect(); if (dlg.ok) { - session = KnoxSession.login(args.get(0), dlg.username, new String(dlg.pass)); - getVariables().put("__knoxsession", session); + session = KnoxSession.login(url, dlg.username, new String(dlg.pass)); + engine.put("__knoxsession", session); + terminal.writer().println("Session established for: " + url); + terminal.writer().flush(); + } else { + terminal.writer().println("Login cancelled."); + terminal.writer().flush(); } } catch (CredentialCollectionException | URISyntaxException e) { - e.printStackTrace(); + terminal.writer().println("Failed to establish session: " + e.getMessage()); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } - return "Session established for: " + args.get(0); + + return session; } public static void main(String[] args) { - LoginCommand cmd = new LoginCommand(new Groovysh()); - List args2 = new ArrayList<>(); - args2.add("https://localhost:8443/gateway"); - cmd.execute(args2); + try { + Terminal terminal = TerminalBuilder.builder().system(true).build(); + GroovyEngine engine = new GroovyEngine(); + LoginCommand cmd = new LoginCommand(engine, terminal); + + List args2 = new ArrayList<>(); + args2.add("https://localhost:8443/gateway/sandbox"); + cmd.execute(args2); + } catch (Exception e) { + e.printStackTrace(); + } } } diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/PurgeCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/PurgeCommand.java new file mode 100644 index 0000000000..3cca28b3d1 --- /dev/null +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/PurgeCommand.java @@ -0,0 +1,137 @@ +/* + * 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.shell.commands; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Clears variables, imports or both from the current shell session. + * Internal Knox variables (prefixed with __knox) are preserved by default. + *

+ * Usage: + *

    + *
  • :purge - clears user variables (preserves internal Knox state)
  • + *
  • :purge variables - same as above
  • + *
  • :purge imports - clears ALL imports, including the built-in Knox + * convenience imports; restart the shell to restore them
  • + *
  • :purge all - clears user variables and ALL imports (see above)
  • + *
+ *

+ */ +public class PurgeCommand extends AbstractKnoxShellCommand { + + private static final String NAME = ":purge"; + private static final String SHORTCUT = ":p"; + private static final String DESC = "Purge variables, classes, imports or preferences"; + private static final String USAGE = "Usage: :purge [variables|imports|all]"; + private static final String HELP = USAGE + "\n" + + " variables - purge user variables, keep internal Knox state\n" + + " imports - purge ALL imports, including built-in Knox imports (restart to restore)\n" + + " all - purge user variables and ALL imports"; + + /** Prefix used by Knox internal bindings (__knoxdatasource, __knoxsession, etc.) */ + private static final String KNOX_INTERNAL_PREFIX = "__knox"; + + /** + * @param engine the GroovyEngine + * @param terminal the JLine terminal + */ + public PurgeCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, NAME, SHORTCUT, DESC, USAGE, HELP); + } + + @Override + public Object execute(List args) { + String what = (args == null || args.isEmpty()) ? "variables" : args.get(0).toLowerCase(Locale.ROOT); + + switch (what) { + case "variables": + int varCount = clearVariables(); + terminal.writer().println("Purged " + varCount + " variable(s). Internal Knox state preserved."); + break; + case "imports": + int importCount = clearImports(); + terminal.writer().println("Purged " + importCount + " import(s)."); + break; + case "all": + int vc = clearVariables(); + int ic = clearImports(); + terminal.writer().println("Purged " + vc + " variable(s) and " + ic + " import(s)."); + break; + default: + terminal.writer().println(USAGE); + break; + } + + terminal.writer().flush(); + return null; + } + + private int clearVariables() { + + Map variables = engine.find(); + if (variables == null || variables.isEmpty()) { + return 0; + } + + int count = 0; + List keysToDelete = new ArrayList<>(); + for (String variableName : variables.keySet()) { + // Preserve internal Knox bindings + if (variableName != null && !variableName.startsWith(KNOX_INTERNAL_PREFIX)) { + keysToDelete.add(variableName); + count++; + } + } + if (!keysToDelete.isEmpty()) { + engine.del(keysToDelete.toArray(new String[0])); + } + return count; + } + + private int clearImports() { + Map imports = engine.getImports(); + + if (imports == null || imports.isEmpty()) { + return 0; + } + + int count = 0; + for (String importName : imports.keySet()) { + engine.removeImport(importName); + count++; + } + return count; + } + + @Override + public List getCompleters() { + Completer subCommandCompleter = new StringsCompleter("variables", "imports", "all"); + return Arrays.asList(subCommandCompleter, NullCompleter.INSTANCE); + } +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/SelectCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/SelectCommand.java index 6f8ca169d8..67d8ecf61a 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/SelectCommand.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/SelectCommand.java @@ -7,7 +7,7 @@ * "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 + * 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, @@ -26,17 +26,19 @@ import java.util.List; import java.util.Map; +import org.apache.knox.gateway.shell.CredentialCollector; +import org.apache.knox.gateway.shell.KnoxDataSource; +import org.apache.knox.gateway.shell.table.KnoxShellTable; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.terminal.Terminal; + import javax.swing.Box; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JScrollPane; import javax.swing.JTextArea; -import org.apache.knox.gateway.shell.CredentialCollector; -import org.apache.knox.gateway.shell.KnoxDataSource; -import org.apache.knox.gateway.shell.table.KnoxShellTable; -import org.apache.groovy.groovysh.Groovysh; - public class SelectCommand extends AbstractSQLCommandSupport implements KeyListener { private static final String USAGE = ":sql [assign resulting-variable-name]"; private static final String DESC = "Build table from SQL ResultSet"; @@ -46,8 +48,8 @@ public class SelectCommand extends AbstractSQLCommandSupport implements KeyListe private List sqlHistory; private int historyIndex = -1; - public SelectCommand(Groovysh shell) { - super(shell, ":SQL", ":sql", DESC, USAGE, DESC); + public SelectCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, ":SQL", ":sql", DESC, USAGE, DESC); } @Override @@ -59,14 +61,14 @@ public void keyPressed(KeyEvent event) { historyIndex = sqlHistory.size() + 1; } if (code == KeyEvent.VK_KP_UP || - code == KeyEvent.VK_UP) { + code == KeyEvent.VK_UP) { if (historyIndex > 0) { historyIndex -= 1; } setFromHistory = true; } else if (code == KeyEvent.VK_KP_DOWN || - code == KeyEvent.VK_DOWN) { + code == KeyEvent.VK_DOWN) { if (historyIndex < sqlHistory.size() - 1) { historyIndex += 1; setFromHistory = true; @@ -87,7 +89,7 @@ public void keyReleased(KeyEvent event) { public void keyTyped(KeyEvent event) { } - @SuppressWarnings({"unchecked", "PMD.CloseResource"}) + @SuppressWarnings({"PMD.CloseResource"}) @Override public Object execute(List args) { boolean ok = false; @@ -95,29 +97,28 @@ public Object execute(List args) { String bindVariableName = null; KnoxShellTable table = null; - if (!args.isEmpty()) { + if (args != null && !args.isEmpty()) { bindVariableName = getBindingVariableNameForResultingTable(args); } - String dsName = (String) getVariables().get(KNOXDATASOURCE); + String dsName = (String) engine.get(KNOXDATASOURCE); Map dataSources = getDataSources(); - KnoxDataSource ds = null; + KnoxDataSource ds; + if (dsName == null || dsName.isEmpty()) { if (dataSources == null || dataSources.isEmpty()) { - return "please configure a datasource with ':datasources add {name} {connectStr} {driver} {authntype: none|basic}'."; - } - else if (dataSources.size() == 1) { + return "Please configure a datasource with ':datasources add {name} {connectStr} {driver} {authntype: none|basic}'."; + } else if (dataSources.size() == 1) { dsName = (String) dataSources.keySet().toArray()[0]; - } - else { - return "mulitple datasources configured. please disambiguate with ':datasources select {name}'."; + } else { + return "Multiple datasources configured. Please disambiguate with ':datasources select {name}'."; } } + ds = dataSources.get(dsName); sqlHistory = getSQLHistory(dsName); historyIndex = (sqlHistory != null && !sqlHistory.isEmpty()) ? sqlHistory.size() - 1 : -1; - ds = dataSources.get(dsName); if (ds != null) { JLabel jl = new JLabel("Query: "); sqlField = new JTextArea(5,40); @@ -132,7 +133,7 @@ else if (dataSources.size() == 1) { SwingUtils.workAroundFocusIssue(sqlField); int x = JOptionPane.showConfirmDialog(null, box, - "SQL Query Input", JOptionPane.OK_CANCEL_OPTION); + "SQL Query Input", JOptionPane.OK_CANCEL_OPTION); if (x == JOptionPane.OK_OPTION) { ok = true; @@ -141,6 +142,7 @@ else if (dataSources.size() == 1) { historyIndex = -1; } + //KnoxShellTable.builder().jdbc().connect("jdbc:derby:codejava/webdb1").driver("org.apache.derby.jdbc.EmbeddedDriver").username("lmccay").pwd("xxxx").sql("SELECT * FROM book"); try { if (ok) { @@ -155,7 +157,8 @@ else if (dataSources.size() == 1) { username = dlg.name(); pass = dlg.chars(); } - conn = getConnection(ds, username, new String(pass)); + String passStr = (pass == null) ? null : new String(pass); + conn = getConnection(ds, username, passStr); } try (Statement statement = conn.createStatement()) { if (statement.execute(sql)) { @@ -164,22 +167,26 @@ else if (dataSources.size() == 1) { } } } - } - catch (SQLException e) { - System.out.println("SQL Exception encountered... " + e.getMessage()); + } catch (SQLException e) { + terminal.writer().println("SQL Exception encountered: " + e.getMessage()); + terminal.writer().flush(); } } + } catch (Exception e) { + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } - catch (Exception e) { - e.printStackTrace(); - } - } - else { - return "please select a datasource via ':datasources select {name}'."; + } else { + return "Please select a datasource via ':datasources select {name}'."; } + if (table != null && bindVariableName != null) { - getVariables().put(bindVariableName, table); + engine.put(bindVariableName, table); + terminal.writer().println("Assigned resulting table to variable: " + bindVariableName); + terminal.writer().flush(); } + return table; } + } diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/ShowCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/ShowCommand.java new file mode 100644 index 0000000000..788b1e1572 --- /dev/null +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/ShowCommand.java @@ -0,0 +1,112 @@ +/* + * 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.shell.commands; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.NullCompleter; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Shows variables, classes or imports in the current shell session. + *

+ * Usage: + * :show - lists all variables (default) + * :show variables - lists all variables + * :show imports - lists active imports + * :show all - lists both variables and imports + */ +public class ShowCommand extends AbstractKnoxShellCommand { + + private static final String NAME = ":show"; + private static final String SHORTCUT = ":S"; + private static final String DESC = "Show variables, imports or both"; + private static final String USAGE = "Usage: :show [variables|imports|all]"; + private static final String HELP = USAGE + "\n" + + " variables - list all bound variables (default)\n" + + " imports - list active import statements\n" + + " all - list both variables and imports"; + + public ShowCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, NAME, SHORTCUT, DESC, USAGE, HELP); + } + + @Override + public Object execute(List args) { + String what = (args == null || args.isEmpty()) ? "variables" : args.get(0).toLowerCase(Locale.ROOT); + + switch (what) { + case "variables": + showVariables(); + break; + case "imports": + showImports(); + break; + case "all": + showVariables(); + terminal.writer().println(); + showImports(); + break; + default: + terminal.writer().println(USAGE); + break; + } + + terminal.writer().flush(); + return null; + } + + private void showVariables() { + Map variables = engine.find(); + if (variables == null || variables.isEmpty()) { + terminal.writer().println("No variables defined."); + return; + } + + terminal.writer().println("Variables:"); + variables.forEach((name, value) -> { + String type = (value != null) ? value.getClass().getSimpleName() : "null"; + String display = (value != null) ? value.toString() : "null"; + terminal.writer().printf(Locale.ROOT, " %-25s (%s) = %s%n", name, type, display); + }); + } + + private void showImports() { + Set imports = engine.getImports().keySet(); + if (imports.isEmpty()) { + terminal.writer().println("No imports registered."); + } else { + terminal.writer().println("Imports:"); + imports.forEach(i -> terminal.writer().println(" import " + i)); + } + } + + @Override + public List getCompleters() { + Completer subCommandCompleter = new StringsCompleter("variables", "imports", "all"); + return Arrays.asList(subCommandCompleter, NullCompleter.INSTANCE); + } + +} diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/WebHDFSCommand.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/WebHDFSCommand.java index 4dc6d88881..c8082f3cf6 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/WebHDFSCommand.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/commands/WebHDFSCommand.java @@ -17,7 +17,6 @@ */ package org.apache.knox.gateway.shell.commands; -import java.io.Console; import java.io.File; import java.io.IOException; import java.net.URISyntaxException; @@ -38,127 +37,157 @@ import org.apache.knox.gateway.shell.hdfs.Status.Response; import org.apache.knox.gateway.shell.table.KnoxShellTable; import org.apache.knox.gateway.util.JsonUtils; -import org.apache.groovy.groovysh.Groovysh; + +import org.apache.groovy.groovysh.jline.GroovyEngine; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; public class WebHDFSCommand extends AbstractKnoxShellCommand { private static final String DESC = "POSIX style commands for Hadoop Filesystems"; private static final String USAGE = "Usage: \n" + - " :fs mounts \n" + - " :fs mount target-topology-url mountpoint-name \n" + - " :fs unmount mountpoint-name \n" + - " :fs ls {target-path} \n" + - " :fs cat {target-path} \n" + - " :fs get {from-path} {to-path} \n" + - " :fs put {from-path} {tp-path} \n" + - " :fs rm {target-path} \n" + - " :fs mkdir {dir-path} \n"; + " :fs mounts \n" + + " :fs mount target-topology-url mountpoint-name \n" + + " :fs unmount mountpoint-name \n" + + " :fs ls {target-path} \n" + + " :fs cat {target-path} \n" + + " :fs get {from-path} {to-path} \n" + + " :fs put {from-path} {to-path} \n" + + " :fs rm {target-path} \n" + + " :fs mkdir {dir-path} \n"; + private Map sessions = new HashMap<>(); - public WebHDFSCommand(Groovysh shell) { - super(shell, ":filesystem", ":fs", DESC, USAGE, DESC); + public WebHDFSCommand(GroovyEngine engine, Terminal terminal) { + super(engine, terminal, ":filesystem", ":fs", DESC, USAGE, USAGE); } @Override public Object execute(List args) { Map mounts = getMountPoints(); - if (args.isEmpty()) { - args.add("ls"); + if (mounts == null) { + mounts = new HashMap<>(); } - if (args.get(0).equalsIgnoreCase("mount")) { - String url = args.get(1); - String mountPoint = args.get(2); - return mount(mounts, url, mountPoint); + + String action = (args == null || args.isEmpty()) ? "ls" : args.get(0); + + if ("mount".equalsIgnoreCase(action)) { + if (args.size() < 3) { + return printError("Usage: :fs mount "); + } + return mount(mounts, args.get(1), args.get(2)); } - else if (args.get(0).equalsIgnoreCase("unmount")) { - String mountPoint = args.get(1); - unmount(mounts, mountPoint); + else if ("unmount".equalsIgnoreCase(action)) { + if (args.size() < 2) { + return printError("Usage: :fs unmount "); + } + unmount(mounts, args.get(1)); + return "Unmounted " + args.get(1); } - else if (args.get(0).equalsIgnoreCase("mounts")) { + else if ("mounts".equalsIgnoreCase(action)) { return listMounts(mounts); } - else if (args.get(0).equalsIgnoreCase("ls")) { - String path = args.get(1); - return listStatus(mounts, path); + else if ("ls".equalsIgnoreCase(action)) { + if (args == null || args.size() < 2) { + return printError("Usage: :fs ls "); + } else { + return listStatus(mounts, args.get(1)); + } } - else if (args.get(0).equalsIgnoreCase("put")) { + else if ("put".equalsIgnoreCase(action)) { // Hdfs.put( session ).file( dataFile ).to( dataDir + "/" + dataFile ).now() // :fs put from-path to-path + if (args.size() < 3) { + return printError("Usage: :fs put [permissions]"); + } String localFile = args.get(1); String path = args.get(2); int permission = 755; if (args.size() >= 4) { - permission = Integer.parseInt(args.get(3)); + try { + permission = Integer.parseInt(args.get(3)); + } catch (NumberFormatException e) { + return printError("Invalid permission format. Expected integer."); + } } - return put(mounts, localFile, path, permission); } - else if (args.get(0).equalsIgnoreCase("rm")) { + else if ("rm".equalsIgnoreCase(action)) { // Hdfs.rm( session ).file( dataFile ).now() // :fs rm target-path - String path = args.get(1); - return remove(mounts, path); + if (args.size() < 2) { + return printError("Usage: :fs rm "); + } + return remove(mounts, args.get(1)); } - else if (args.get(0).equalsIgnoreCase("cat")) { + else if ("cat".equalsIgnoreCase(action)) { // println Hdfs.get( session ).from( dataDir + "/" + dataFile ).now().string // :fs cat target-path - String path = args.get(1); - return cat(mounts, path); + if (args.size() < 2) { + return printError("Usage: :fs cat "); + } + return cat(mounts, args.get(1)); } - else if (args.get(0).equalsIgnoreCase("mkdir")) { + else if ("mkdir".equalsIgnoreCase(action)) { // println Hdfs.mkdir( session ).dir( directoryPath ).perm( "777" ).now().string // :fs mkdir target-path [perms] - String path = args.get(1); - String perms = null; - if (args.size() == 3) { - perms = args.get(2); + if (args.size() < 2) { + return printError("Usage: :fs mkdir [perms]"); } - - return mkdir(mounts, path, perms); + String perms = (args.size() == 3) ? args.get(2) : null; + return mkdir(mounts, args.get(1), perms); } - else if (args.get(0).equalsIgnoreCase("get")) { + else if ("get".equalsIgnoreCase(action)) { // println Hdfs.get( session ).from( dataDir + "/" + dataFile ).now().string // :fs get from-path [to-path] + if (args.size() < 2) { + return printError("Usage: :fs get [to-path]"); + } String path = args.get(1); - String mountPoint = determineMountPoint(path); KnoxSession session = getSessionForMountPoint(mounts, mountPoint); + if (session != null) { String from = determineTargetPath(path, mountPoint); - String to = null; - if (args.size() > 2) { - to = args.get(2); - } - else { - to = System.getProperty("user.home") + File.separator + - path.substring(path.lastIndexOf(File.separator)); - } + String to = (args.size() > 2) ? args.get(2) : + System.getProperty("user.home") + File.separator + getFileName(path); return get(mountPoint, from, to); + } else { + return "No session established for mountPoint: " + mountPoint + ". Use :fs mount {topology-url} {mountpoint-name}"; } - else { - return "No session established for mountPoint: " + mountPoint + " Use :fs mount {topology-url} {mountpoint-name}"; - } - } - else { - System.out.println("Unknown filesystem command"); - System.out.println(getUsage()); + } else { + terminal.writer().println("Unknown filesystem command: " + action); + terminal.writer().println(getUsage()); + terminal.writer().flush(); } return ""; } + private String printError(String msg) { + terminal.writer().println("Error: " + msg); + terminal.writer().flush(); + return null; + } + + // HELPER to safely extract filename + private String getFileName(String path) { + int index = path.lastIndexOf(File.separator); + return (index > -1) ? path.substring(index) : path; + } + private String get(String mountPoint, String from, String to) { - String result = null; try { Hdfs.get(sessions.get(mountPoint)).from(from).file(to).now().getString(); - result = "Successfully copied: " + from + " to: " + to; + return "Successfully copied: " + from + " to: " + to; } catch (KnoxShellException | IOException e) { - e.printStackTrace(); - result = "Exception ocurred: " + e.getMessage(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return "Exception occurred: " + e.getMessage(); } - return result; } private String mkdir(Map mounts, String path, String perms) { - String result = null; String mountPoint = determineMountPoint(path); KnoxSession session = getSessionForMountPoint(mounts, mountPoint); if (session != null) { @@ -166,45 +195,37 @@ private String mkdir(Map mounts, String path, String perms) { if (!exists(session, targetPath)) { try { if (perms != null) { - Hdfs.mkdir(sessions.get(mountPoint)).dir(targetPath).now().getString(); + Hdfs.mkdir(sessions.get(mountPoint)).dir(targetPath).perm(perms).now().getString(); + } else { + Hdfs.mkdir(session).dir(targetPath).now().getString(); } - else { - Hdfs.mkdir(session).dir(targetPath).perm(perms).now().getString(); - } - result = "Successfully created directory: " + targetPath; + return "Successfully created directory: " + targetPath; } catch (KnoxShellException | IOException e) { - e.printStackTrace(); - result = "Exception ocurred: " + e.getMessage(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return "Exception occurred: " + e.getMessage(); } - } - else { - result = targetPath + " already exists"; + } else { + return targetPath + " already exists"; } } - else { - result = "No session established for mountPoint: " + mountPoint + " Use :fs mount {topology-url} {mountpoint-name}"; - } - return result; + return "No session established for mountPoint: " + mountPoint; } private String cat(Map mounts, String path) { - String response = null; String mountPoint = determineMountPoint(path); KnoxSession session = getSessionForMountPoint(mounts, mountPoint); if (session != null) { String targetPath = determineTargetPath(path, mountPoint); try { - String contents = Hdfs.get(session).from(targetPath).now().getString(); - response = contents; + return Hdfs.get(session).from(targetPath).now().getString(); } catch (KnoxShellException | IOException e) { - e.printStackTrace(); - response = "Exception ocurred: " + e.getMessage(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return "Exception occurred: " + e.getMessage(); } } - else { - response = "No session established for mountPoint: " + mountPoint + " Use :fs mount {topology-url} {mountpoint-name}"; - } - return response; + return "No session established for mountPoint: " + mountPoint; } private String remove(Map mounts, String path) { @@ -215,11 +236,11 @@ private String remove(Map mounts, String path) { try { Hdfs.rm(session).file(targetPath).now().getString(); } catch (KnoxShellException | IOException e) { - e.printStackTrace(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } - } - else { - return "No session established for mountPoint: " + mountPoint + " Use :fs mount {topology-url} {mountpoint-name}"; + } else { + return "No session established for mountPoint: " + mountPoint; } return "Successfully removed: " + path; } @@ -232,70 +253,68 @@ private String put(Map mounts, String localFile, String path, in try { boolean overwrite = false; if (exists(session, targetPath)) { - if (collectClearInput(targetPath + " already exists would you like to overwrite (Y/n)").equalsIgnoreCase("y")) { + //Replaced System.console() with JLine 3 input + String answer = collectClearInput(targetPath + " already exists. Would you like to overwrite? (Y/n): "); + if (answer != null && answer.trim().equalsIgnoreCase("y")) { overwrite = true; + } else { + return "Put operation cancelled."; } } Hdfs.put(session).file(localFile).to(targetPath).overwrite(overwrite).permission(permission).now().getString(); } catch (IOException e) { - e.printStackTrace(); - return "Exception ocurred: " + e.getMessage(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return "Exception occurred: " + e.getMessage(); } - } - else { - return "No session established for mountPoint: " + mountPoint + " Use :fs mount {topology-url} {mountpoint-name}"; + } else { + return "No session established for mountPoint: " + mountPoint; } return "Successfully put: " + localFile + " to: " + path; } private boolean exists(KnoxSession session, String path) { - boolean rc = false; try { Response response = Hdfs.status(session).file(path).now(); - rc = response.exists(); + return response.exists(); } catch (KnoxShellException e) { - // NOP + return false; } - return rc; } private Object listStatus(Map mounts, String path) { - Object response = null; try { - String directory; String mountPoint = determineMountPoint(path); if (mountPoint != null) { KnoxSession session = getSessionForMountPoint(mounts, mountPoint); if (session != null) { - directory = determineTargetPath(path, mountPoint); + String directory = determineTargetPath(path, mountPoint); String json = Hdfs.ls(session).dir(directory).now().getString(); - Map>>> map = - JsonUtils.getFileStatusesAsMap(json); - if (map != null) { + + Map>>> map = JsonUtils.getFileStatusesAsMap(json); + if (map != null && map.containsKey("FileStatuses")) { ArrayList> list = map.get("FileStatuses").get("FileStatus"); - KnoxShellTable table = buildTableFromListStatus(directory, list); - response = table; + return buildTableFromListStatus(directory, list); } + } else { + return "No session established for mountPoint: " + mountPoint; } - else { - response = "No session established for mountPoint: " + mountPoint + " Use :fs mount {topology-url} {mountpoint-name}"; - } - } - else { - response = "No mountpoint found. Use ':fs mount {topologyURL} {mountpoint}'."; + } else { + return "No mountPoint found. Use ':fs mount {topologyURL} {mountPoint}'."; } } catch (KnoxShellException | IOException e) { - response = "Exception ocurred: " + e.getMessage(); - e.printStackTrace(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return "Exception occurred: " + e.getMessage(); } - return response; + return null; } private KnoxShellTable listMounts(Map mounts) { KnoxShellTable table = new KnoxShellTable(); table.header("Mount Point").header("Topology URL"); - for (String mountPoint : mounts.keySet()) { - table.row().value(mountPoint).value(mounts.get(mountPoint)); + for (Map.Entry entry : mounts.entrySet()) { + table.row().value(entry.getKey()).value(entry.getValue()); } return table; } @@ -332,31 +351,31 @@ private KnoxSession establishSession(String mountPoint, String url) { try { dlg = login(); } catch (CredentialCollectionException e) { - e.printStackTrace(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); return null; } String username = dlg.name(); String password = new String(dlg.chars()); - KnoxSession session = null; try { - session = KnoxSession.login(url, username, password); + KnoxSession session = KnoxSession.login(url, username, password); sessions.put(mountPoint, session); + return session; } catch (URISyntaxException e) { - e.printStackTrace(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); + return null; } - return session; } + // Safely prompt for input using JLine 3 private String collectClearInput(String prompt) { - Console c = System.console(); - if (c == null) { - System.err.println("No console."); - System.exit(1); + try { + LineReader reader = LineReaderBuilder.builder().terminal(terminal).build(); + return reader.readLine(prompt); + } catch (Exception e) { + return ""; // Fallback gracefully if interrupted } - - String value = c.readLine(prompt); - - return value; } private String determineTargetPath(String path, String mountPoint) { @@ -373,14 +392,15 @@ private String stripMountPoint(String path, String mountPoint) { } private String determineMountPoint(String path) { - String mountPoint = null; - if (path.startsWith("/")) { + if (path != null && path.startsWith("/")) { // does the user supplied path starts at a root // if so check for a mountPoint based on the first element of the path String[] pathElements = path.split("/"); - mountPoint = pathElements[1]; + if (pathElements.length > 1) { + return pathElements[1]; + } } - return mountPoint; + return null; } private KnoxShellTable buildTableFromListStatus(String directory, List> list) { @@ -394,32 +414,43 @@ private KnoxShellTable buildTableFromListStatus(String directory, List map : list) { - cal.setTimeInMillis(Long.parseLong(map.get("modificationTime"))); - table.row() + if (list != null) { + for (Map map : list) { + cal.setTimeInMillis(Long.parseLong(map.get("modificationTime"))); + table.row() .value(map.get("permission")) .value(map.get("owner")) .value(map.get("group")) .value(map.get("length")) .value(cal.getTime()) .value(map.get("pathSuffix")); + } } - return table; } protected Map getMountPoints() { - Map mounts = null; try { - mounts = KnoxSession.loadMountPoints(); + return KnoxSession.loadMountPoints(); } catch (IOException e) { - e.printStackTrace(); + e.printStackTrace(terminal.writer()); + terminal.writer().flush(); } - return mounts; + return null; } public static void main(String[] args) { - WebHDFSCommand cmd = new WebHDFSCommand(new Groovysh()); - cmd.execute(new ArrayList<>(Arrays.asList(args))); + try { + Terminal terminal = TerminalBuilder.builder().system(true).build(); + GroovyEngine engine = new GroovyEngine(); + WebHDFSCommand cmd = new WebHDFSCommand(engine, terminal); + Object result = cmd.execute(new ArrayList<>(Arrays.asList(args))); + if (result != null) { + terminal.writer().println(result); + terminal.writer().flush(); + } + } catch (Exception e) { + e.printStackTrace(); + } } } diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTable.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTable.java index d83fc3a6d7..c5d05bbaa8 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTable.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTable.java @@ -22,8 +22,8 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import javax.swing.SortOrder; import com.fasterxml.jackson.annotation.JsonFilter; @@ -292,7 +292,7 @@ public KnoxShellTable apply(KnoxShellTableCell { + long now = System.currentTimeMillis(); + // If we are moving too fast, artificially step forward by 1ms to avoid collision + return (now > lastTime) ? now : lastTime + 1; + }); } public List getCallHistoryList() { diff --git a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistory.java b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistory.java index 77451de541..5cfa125d65 100644 --- a/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistory.java +++ b/gateway-shell/src/main/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistory.java @@ -66,6 +66,14 @@ void removeCallsById(long id) { callHistory.remove(id); } + /** + * Clears the entire call history. + * Useful for ensuring clean state between unit tests. + */ + void clear() { + callHistory.clear(); + } + public List getCallHistory(long id) { return callHistory.containsKey(id) ? Collections.unmodifiableList(callHistory.get(id)) : Collections.emptyList(); } diff --git a/gateway-shell/src/main/resources/META-INF/aop.xml b/gateway-shell/src/main/resources/META-INF/aop.xml index 0070403377..7f4fa2514a 100644 --- a/gateway-shell/src/main/resources/META-INF/aop.xml +++ b/gateway-shell/src/main/resources/META-INF/aop.xml @@ -20,7 +20,7 @@ - + diff --git a/gateway-shell/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule b/gateway-shell/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule new file mode 100644 index 0000000000..cc06c092ad --- /dev/null +++ b/gateway-shell/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule @@ -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. +########################################################################## + +moduleName=knox-shell-merged-extensions +moduleVersion=3.0.0-SNAPSHOT +extensionClasses=org.apache.groovy.swing.extensions.SwingExtensions,org.apache.groovy.nio.extensions.NioExtensions,org.apache.groovy.xml.extensions.XmlExtensions +staticExtensionClasses= diff --git a/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistoryTest.java b/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistoryTest.java index edb9f2f518..1b99962f5c 100644 --- a/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistoryTest.java +++ b/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableCallHistoryTest.java @@ -26,6 +26,8 @@ import java.util.LinkedList; import java.util.List; +import org.junit.After; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -49,6 +51,17 @@ public static void init() { CALL_LIST.add(new KnoxShellTableCall("org.apache.knox.gateway.shell.table.KnoxShellTableFilter", "greaterThan", true, Collections.singletonMap("5", String.class))); } + @Before + public void setUp() { + KnoxShellTableCallHistory.getInstance().clear(); + } + + @After + public void tearDown() { + KnoxShellTableCallHistory.getInstance().clear(); + } + + @Test public void shouldReturnEmptyListInCaseThereWasNoCall() throws Exception { final long id = KnoxShellTable.getUniqueTableId(); @@ -122,7 +135,7 @@ public void shouldRollbackToValidPreviousStep() throws Exception { table.rollback(); assertNotNull(table); assertEquals(14, table.rows.size()); - assertEquals(table.values(0).get(13), "14"); // selected the first column (ZIP) where the last element - index 13 - is 14 + assertEquals("14", table.values(0).get(13)); // selected the first column (ZIP) where the last element - index 13 - is 14 } private void recordCallHistory(long id, int steps) { diff --git a/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableTest.java b/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableTest.java index 5835dbe45c..b73e20e487 100644 --- a/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableTest.java +++ b/gateway-shell/src/test/java/org/apache/knox/gateway/shell/table/KnoxShellTableTest.java @@ -55,6 +55,8 @@ import org.apache.knox.gateway.shell.jdbc.Database; import org.apache.knox.gateway.shell.jdbc.derby.DerbyDatabase; import org.easymock.IAnswer; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -67,6 +69,16 @@ public class KnoxShellTableTest { private static final String SYSTEM_PROPERTY_DERBY_STREAM_ERROR_FILE = "derby.stream.error.file"; private static final String SAMPLE_DERBY_DATABASE_NAME = "sampleDerbyDatabase"; + @Before + public void setUp() { + KnoxShellTableCallHistory.getInstance().clear(); + } + + @After + public void tearDown() { + KnoxShellTableCallHistory.getInstance().clear(); + } + @Test public void testSimpleTableRendering() { String expectedResult = "+------------+------------+------------+\n" 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..06c4ca7c16 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 @@ -1355,8 +1355,49 @@ public List getLDAPSSLEnabledCipherSuites() { return Collections.emptyList(); } + @Override + public int getLDAPMaxSizeLimit() { + return 0; + } + + @Override + public int getLDAPMaxTimeLimit() { + return 0; + } + @Override 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; + } + + @Override + public int getKnoxIDFFederatedOpConnectTimeoutMs() { + return KNOXIDF_FEDERATED_OP_CONNECT_TIMEOUT_MS_DEFAULT; + } + + @Override + public int getKnoxIDFFederatedOpReadTimeoutMs() { + return KNOXIDF_FEDERATED_OP_READ_TIMEOUT_MS_DEFAULT; + } + } 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..b1b0e4eb04 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java @@ -21,6 +21,7 @@ import java.net.UnknownHostException; import java.security.KeyStore; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -72,6 +73,10 @@ public interface GatewayConfig { String SIGNING_KEYSTORE_PASSWORD_ALIAS = "gateway.signing.keystore.password.alias"; String SIGNING_KEYSTORE_TYPE = "gateway.signing.keystore.type"; String SIGNING_KEY_ALIAS = "gateway.signing.key.alias"; + // Comma-separated list of additional signing-keystore aliases whose public keys are published on + // the JWKS endpoint and accepted (selected by 'kid') when verifying gateway-signed JWTs. Lets an + // operator retain a previous key across a manual key rotation so already-issued tokens still verify. + String SIGNING_KEY_ALIASES_ADDITIONAL = "gateway.signing.key.aliases.additional"; String SIGNING_KEY_PASSPHRASE_ALIAS = "gateway.signing.key.passphrase.alias"; String DEFAULT_SIGNING_KEYSTORE_PASSWORD_ALIAS = "signing.keystore.password"; String DEFAULT_SIGNING_KEYSTORE_TYPE = KeyStore.getDefaultType(); @@ -155,6 +160,28 @@ public interface GatewayConfig { String LDAP_SSL_KEYSTORE_PATH = "gateway.ldap.ssl.keystore.path"; String LDAP_SSL_KEYSTORE_PASSWORD_ALIAS = "gateway.ldap.ssl.keystore.password.alias"; String LDAP_SSL_ENABLED_CIPHER_SUITES = "gateway.ldap.ssl.enabled.cipher.suites"; + String LDAP_MAX_SIZE_LIMIT = "gateway.ldap.max.size.limit"; + String LDAP_MAX_TIME_LIMIT = "gateway.ldap.max.time.limit"; + + // TrustedOidcIssuerService gateway-level params and their default values + String TRUSTED_OIDC_ISSUER_PREFIX = "gateway.trusted.oidc.issuer."; + String TRUSTED_OIDC_ISSUER_MAX_TRUSTED_ISSUERS = TRUSTED_OIDC_ISSUER_PREFIX + "max.issuers"; + int TRUSTED_OIDC_ISSUER_MAX_TRUSTED_ISSUERS_DEFAULT = 10_000; + String TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX = TRUSTED_OIDC_ISSUER_PREFIX + "discovery."; + String TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS = TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX + "cache.ttl.secs"; + int TRUSTED_OIDC_ISSUER_DISCOVERY_CACHE_TTL_SECS_DEFAULT = 600; + String TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS = TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX + "connect.timeout.ms"; + int TRUSTED_OIDC_ISSUER_DISCOVERY_CONNECT_TIMEOUT_MS_DEFAULT = 3000; + String TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS = TRUSTED_OIDC_ISSUER_DISCOVERY_PREFIX+ "read.timeout.ms"; + int TRUSTED_OIDC_ISSUER_DISCOVERY_READ_TIMEOUT_MS_DEFAULT = 10000; + + // KnoxIDF federated-OP back-channel (token exchange) HTTP client timeouts. Without these an + // unresponsive external OP token endpoint pins the calling request thread indefinitely. + String KNOXIDF_FEDERATED_OP_PREFIX = "gateway.knoxidf.federated.op."; + String KNOXIDF_FEDERATED_OP_CONNECT_TIMEOUT_MS = KNOXIDF_FEDERATED_OP_PREFIX + "connect.timeout.ms"; + int KNOXIDF_FEDERATED_OP_CONNECT_TIMEOUT_MS_DEFAULT = 3000; + String KNOXIDF_FEDERATED_OP_READ_TIMEOUT_MS = KNOXIDF_FEDERATED_OP_PREFIX + "read.timeout.ms"; + int KNOXIDF_FEDERATED_OP_READ_TIMEOUT_MS_DEFAULT = 10000; /** * The location of the gateway configuration. @@ -449,6 +476,21 @@ public interface GatewayConfig { */ String getSigningKeyPassphraseAlias(); + /** + * Returns the ordered list of signing-keystore aliases whose public keys the gateway publishes on + * the JWKS endpoint and accepts (selected by {@code kid}) when verifying gateway-signed JWTs. The + * current signing key ({@link #getSigningKeyAlias()}) is always first; any additional + * verification-only keys (e.g. a previous key retained across a manual key rotation) follow. + *

+ * Implementations that do not support additional keys return just the current signing key, so a + * single-key deployment behaves exactly as before. + * + * @return the current signing key alias followed by any additional verification key aliases + */ + default List getSigningKeyAliases() { + return getSigningKeyAlias() == null ? Collections.emptyList() : Collections.singletonList(getSigningKeyAlias()); + } + List getGlobalRulesServices(); @@ -1211,10 +1253,33 @@ public interface GatewayConfig { */ List getLDAPSSLEnabledCipherSuites(); + /** + * @return the maximum size limit for LDAP search + */ + int getLDAPMaxSizeLimit(); + + /** + * @return the maximum time limit for LDAP search in milliseconds + */ + int getLDAPMaxTimeLimit(); + /** * @return set of all property names in the configuration */ Set getPropertyNames(); boolean getGroupUIServicesOnHomepage(); + + int getTrustedOidcIssuerMaxTrustedIssuers(); + + int getTrustedOidcIssuerDiscoveryCacheTtlSecs(); + + int getTrustedOidcIssuerDiscoveryConnectTimeoutMs(); + + int getTrustedOidcIssuerDiscoveryReadTimeoutMs(); + + int getKnoxIDFFederatedOpConnectTimeoutMs(); + + int getKnoxIDFFederatedOpReadTimeoutMs(); + } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/i18n/GatewaySpiMessages.java b/gateway-spi/src/main/java/org/apache/knox/gateway/i18n/GatewaySpiMessages.java index 7217499706..a067af3b53 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/i18n/GatewaySpiMessages.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/i18n/GatewaySpiMessages.java @@ -76,6 +76,9 @@ public interface GatewaySpiMessages { @Message( level = MessageLevel.ERROR, text = "Topology {0} cannot be manually overwritten because it was generated from a simple descriptor." ) void disallowedOverwritingGeneratedTopology(String topologyName); + @Message( level = MessageLevel.INFO, text = "Read-only topology {0} cannot be overwritten." ) + void disallowedOverwritingReadOnlyTopology(String topologyName); + @Message( level = MessageLevel.INFO, text = "Read-only descriptor {0} cannot be overwritten." ) void disallowedOverwritingGeneratedDescriptor(String name); 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..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 @@ -40,7 +40,9 @@ 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"), + 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/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..cb674bb8e2 --- /dev/null +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityService.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.services.knoxidf.federation; + +import org.apache.knox.gateway.services.Service; + +import java.util.Optional; + +public interface FederatedIdentityService extends Service { + + /** + * Persists the identity and returns the canonical stored row. If a concurrent request already + * inserted the same external identity, the returned identity is the one that won the race + * (the row actually in the table), never the caller's in-memory copy. + */ + FederatedIdentity 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/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(); +} 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..1e4fb96b5f 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,10 @@ public class JWTokenAttributesBuilder { private String userName; private List audiences; private String algorithm; + // Default to the builder's creation time so every issued token carries a correct 'iat'. + // Callers that need a specific issue time (e.g. managed-token flows) override this via + // setIssueTime(). Without this default, JWTToken would emit iat=epoch-0 (1970). + private long issueTime = System.currentTimeMillis(); private long expires; private String signingKeystoreName; private String signingKeystoreAlias; @@ -40,6 +44,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 +65,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 +154,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/TokenStateService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java index 2d3ea1b203..82d3331ee8 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java @@ -102,6 +102,32 @@ public interface TokenStateService extends Service { */ void revokeToken(String tokenId) throws UnknownTokenException; + /** + * Atomically consume (revoke) the specified token, reporting whether this caller + * performed the removal. This enforces single-use semantics (e.g. OAuth authorization codes) + * under concurrent redemption: of N callers racing to consume the same token, exactly one + * receives {@code true} and all others receive {@code false} because the token was already gone. + * Unlike {@link #revokeToken(String)}, an absent token is reported as {@code false} rather than + * raising {@link UnknownTokenException}. + *

+ * The default implementation delegates to {@link #revokeToken(String)} and is only as atomic as + * that method; implementations backed by a store that can remove-and-report atomically (a + * concurrent-map removal or a primary-key {@code DELETE}) should override this to provide a true + * single-winner guarantee. + * + * @param tokenId The token unique identifier. + * @return {@code true} iff this invocation removed a present token; {@code false} if it was + * already absent (never existed, or consumed by a concurrent caller). + */ + default boolean consumeToken(String tokenId) { + try { + revokeToken(tokenId); + return true; + } catch (UnknownTokenException e) { + return false; + } + } + /** * Extend the lifetime of the specified token by the default amount of time. * diff --git a/gateway-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-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java index c4ab7d7ba4..becb9c32ce 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java @@ -86,6 +86,34 @@ public void testTokenCreation() throws Exception { assertTrue("Missing ALG claim in JWT header", token.getHeader().contains(ALGO)); } + @Test + public void testIssueTimeDefaultsToNow() throws Exception { + // Regression: JWTToken always emits 'iat'. When a caller does not set the issue time, the + // builder must default it to "now" rather than leaving it at epoch-0 (1970). + final long before = System.currentTimeMillis(); + final JWT token = new JWTToken(new JWTokenAttributesBuilder() + .setUserName("john.doe@example.com").setAlgorithm("RS256").build()); + final long after = System.currentTimeMillis(); + + final Date issueTime = token.getJWTClaimsSet().getIssueTime(); + assertNotNull("iat must be present", issueTime); + // JWT 'iat' has second precision, so allow the surrounding second as slack. + assertTrue("iat must be ~now, not 1970 (was " + issueTime + ")", + issueTime.getTime() >= (before - 1000L) && issueTime.getTime() <= (after + 1000L)); + } + + @Test + public void testIssueTimeIsHonouredWhenSet() throws Exception { + final long explicit = 1_600_000_000_000L; // 2020-09-13 + final JWT token = new JWTToken(new JWTokenAttributesBuilder() + .setUserName("john.doe@example.com").setAlgorithm("RS256").setIssueTime(explicit).build()); + + final Date issueTime = token.getJWTClaimsSet().getIssueTime(); + assertNotNull(issueTime); + // second precision + assertEquals(explicit / 1000L, issueTime.getTime() / 1000L); + } + @Test public void testPrivateUUIDClaim() throws Exception { JWT token = new JWTToken(new JWTokenAttributesBuilder().setAudiences(singletonList("https://login.example.com")).setUserName("john.doe@example.com").setAlgorithm("RS256").build()); diff --git a/gateway-test/src/test/java/org/apache/knox/gateway/GatewayAdminTopologyFuncTest.java b/gateway-test/src/test/java/org/apache/knox/gateway/GatewayAdminTopologyFuncTest.java index 4928ed920b..417ab5949a 100644 --- a/gateway-test/src/test/java/org/apache/knox/gateway/GatewayAdminTopologyFuncTest.java +++ b/gateway-test/src/test/java/org/apache/knox/gateway/GatewayAdminTopologyFuncTest.java @@ -1806,7 +1806,8 @@ public void testPutDescriptorWithValidEncodedName() throws Exception { String newDescriptorJSON = createDescriptor(clusterName); // Attempt to PUT the descriptor - given().auth().preemptive().basic(username, password) + given().urlEncodingEnabled(false) + .auth().preemptive().basic(username, password) .header("Content-type", MediaType.APPLICATION_JSON) .body(newDescriptorJSON.getBytes(StandardCharsets.UTF_8.name())) .then() 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/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"; } 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..4f01eaddf2 --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/AuthorizeRequestMetadata.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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"); + } + + // Require state for CSRF protection: it is echoed back on the redirect and the client + // must match it against the value it generated. Without it the auth-code flow is open to + // login-CSRF, and redirectToAuthSuccess would NPE URL-encoding a null state. + if (state == null || state.isEmpty()) { + return error("invalid_request", "Missing state"); + } + + // Verify scope(s) + if (requestedScopes == null || requestedScopes.isEmpty()) { + return error("invalid_scope", "Missing scopes"); + } 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/FederatedNonceStore.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedNonceStore.java new file mode 100644 index 0000000000..a3b30bf701 --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedNonceStore.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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; + +/** + * Holds the OIDC {@code nonce} that Knox generates and sends to a federated OP, keyed by the + * federated login-session id (the {@code state} echoed by the OP). The value is written when the OP + * authorization redirect is built and read once when the OP callback is processed, so the returned + * id_token's {@code nonce} claim can be bound to this specific authorization request. Like the other + * KnoxIDF artifact stores this is a JVM singleton (the redirect is built in one topology/webapp and + * the callback handled in another, within the same JVM) and its entries are single-use: callers + * {@code remove()} the nonce after verifying it so a replayed callback cannot reuse it. + */ +public class FederatedNonceStore extends KnoxIDFArtifactStore { + + private static FederatedNonceStore instance; + + private FederatedNonceStore(long ttl) { + super(ttl); + } + + public static synchronized FederatedNonceStore getInstance(long ttl) { + if (instance == null) { + instance = new FederatedNonceStore(ttl); + } + return instance; + } +} diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java new file mode 100644 index 0000000000..b82d344416 --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java @@ -0,0 +1,119 @@ +/* + * 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 clientSecretAlias; + private final String tokenEndpoint; + private final String authorizeEndpoint; + private final String userInfoEndpoint; + private final String discoveryEndpoint; + private final String authorizeCallback; + private final String jwksEndpoint; + private final String issuer; + private final String signatureAlgorithm; + + // Default signature algorithm expected for the OP's id_token when not explicitly configured. + static final String DEFAULT_SIGNATURE_ALGORITHM = "RS256"; + + public FederatedOpConfiguration(final ServletContext servletContext, final String opName) { + this.name = opName; + 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"); + // Preferred, secure source for the OP client secret: an AliasService credential alias. + // Resolved at point of use (AuthorizeResource) since this holder has no access to services. + // When set it takes precedence over the plaintext clientSecret param above. + this.clientSecretAlias = servletContext.getInitParameter(prefix + "clientSecret.alias"); + this.tokenEndpoint = servletContext.getInitParameter(prefix + "token.endpoint"); + this.authorizeEndpoint = servletContext.getInitParameter(prefix + "authorize.endpoint"); + this.authorizeCallback = servletContext.getInitParameter(prefix + "authorize.callback"); + this.userInfoEndpoint = servletContext.getInitParameter(prefix + "userinfo.endpoint"); + this.discoveryEndpoint = servletContext.getInitParameter(prefix + "discovery.endpoint"); + // Used to validate the OP's id_token (signature via JWKS, expected issuer). See + // AuthorizeResource#validateFederatedIdToken - federated login fails closed without these. + this.jwksEndpoint = servletContext.getInitParameter(prefix + "jwks.endpoint"); + this.issuer = servletContext.getInitParameter(prefix + "issuer"); + final String configuredAlg = servletContext.getInitParameter(prefix + "signature.algorithm"); + this.signatureAlgorithm = configuredAlg == null || configuredAlg.isEmpty() ? DEFAULT_SIGNATURE_ALGORITHM : configuredAlg; + } + + public String getName() { + return name; + } + + public boolean isEnabled() { + return enabled; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + /** + * @return the name of the AliasService credential alias holding this OP's client secret, or + * {@code null}/blank when the deployment supplies the secret via the plaintext + * {@code clientSecret} param instead. When set, the alias is authoritative. + */ + public String getClientSecretAlias() { + return clientSecretAlias; + } + + String getAuthorizeEndpoint() { + return authorizeEndpoint; + } + + public String getAuthorizeCallback() { + return authorizeCallback; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getUserInfoEndpoint() { + return userInfoEndpoint; + } + + public String getDiscoveryEndpoint() { + return discoveryEndpoint; + } + + public String getJwksEndpoint() { + return jwksEndpoint; + } + + public String getIssuer() { + return issuer; + } + + public String getSignatureAlgorithm() { + return signatureAlgorithm; + } + +} diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/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..94ab55122f --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStore.java @@ -0,0 +1,48 @@ +/* + * 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) { + // Entries live for twice the caller's TTL as a deliberate grace window: an artifact (e.g. an + // in-flight authorize/consent request) is created against the token TTL but must survive the + // extra round-trip through the browser/consent screen before it is consumed. Callers that + // finish with an entry earlier should remove() it rather than wait for expiry. + this.cache = Caffeine.newBuilder().expireAfterWrite(ttl * 2, TimeUnit.MILLISECONDS).build(); + } + + public void put(String key, T value) { + cache.put(key, value); + } + + public T get(String key) { + return cache.getIfPresent(key); + } + + /** Invalidates an entry so a single-use artifact cannot be replayed within its TTL grace window. */ + public void remove(String key) { + cache.invalidate(key); + } +} diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java new file mode 100644 index 0000000000..8c8cf6581e --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java @@ -0,0 +1,99 @@ +/* + * 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.ImmutableSet; + +import java.util.Set; + +public interface KnoxIDFConstants { + String BASE_RESOURCE_PATH = "knoxidf/api/v1"; + String AUTH_CODE = "authorization_code"; + String CLIENT_ID = "client_id"; + String REDIRECT_URI = "redirect_uri"; + String REDIRECT_URIS = "redirect_uris"; + String RESPONSE_TYPE = "response_type"; + // Immutable: an interface field is implicitly public static final, but a mutable HashSet would + // still let any caller add()/remove() on the shared instance. ImmutableSet forbids that. + Set ALLOWED_RESPONSE_TYPES = ImmutableSet.of("code", "id_token", "code id_token"); + String SCOPE = "scope"; + String ALLOWED_SCOPES = "allowed_scopes"; + String OFFLINE_ACCESS_SCOPE = "offline_access"; + // Immutable shared constant; callers that need a mutable working set copy it (new HashSet<>(...)). + Set DEFAULT_SCOPES = ImmutableSet.of("openid", "profile", "email", OFFLINE_ACCESS_SCOPE); + // The OIDC-standard scope set (OIDC Core 5.4 + offline_access). Used as the default bound on what + // scopes a client may register when the operator has not configured an explicit whitelist. Matches + // the baseline registerable set of well-known OPs (Okta/Auth0/Keycloak), so no standards-compliant + // client is rejected, while non-standard scopes (e.g. 'admin') are refused unless explicitly allowed. + Set OIDC_STANDARD_SCOPES = ImmutableSet.of("openid", "profile", "email", "address", "phone", 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 TOKEN_ISS_ATTRIBUTE = "X-Token-Iss"; + String SCOPE_ATTRIBUTE = "X-Token-Scope"; + + String FEDERATED_IDENTITY_ID = "federated_identity_id"; + 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"; + + // When false (the default), the dynamic client-registration endpoint refuses anonymous callers + // even if the topology wires it as 'anon'. Deployments that intend open, unauthenticated + // registration must explicitly set this to true (see the sample knoxidf topologies). + String CLIENT_REGISTRATION_ANONYMOUS_ALLOWED = "knoxidf.client.registration.anonymous.allowed"; + + // Comma-separated hostnames that, in addition to the hard-coded loopback set (localhost/127.0.0.1/::1), + // are permitted to use a plain-HTTP redirect_uri during dynamic client registration. Intended for + // dev setups where the callback host is not literally loopback but is equally trusted (e.g. + // 'host.docker.internal'). SECURITY: plain HTTP redirects to these hosts traverse a (virtual) + // network, so only add hosts you fully control. Empty/undefined => today's behavior (loopback only). + String CLIENT_REGISTRATION_CUSTOM_LOOPBACK_HOSTS = "knoxidf.custom.loopback.hosts"; + + // Comma-separated server-side whitelist of scopes a client is permitted to register in its + // allowed_scopes. A client cannot self-assign a scope outside this set, so it cannot mint tokens + // carrying a privileged scope name that a downstream service might trust. Undefined/blank => + // defaults to the OIDC-standard scope set (see OIDC_STANDARD_SCOPES). 'openid' is always required + // in a client's allowed_scopes regardless of this list. + String CLIENT_REGISTRATION_ALLOWED_SCOPES = "knoxidf.registration.allowed.scopes"; + + // 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/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..f2f0e5ae37 --- /dev/null +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java @@ -0,0 +1,125 @@ +/* + * 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.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + + +public class KnoxIDFUtils { + + /** + * Builds an OAuth 2.0 error response, deriving the HTTP status from the error code per + * RFC 6749 §5.2 (rather than the previous always-401). Most protocol errors are client errors + * (400); {@code invalid_client} is an authentication failure (401), {@code access_denied} is a + * policy denial (403), and {@code server_error} is 500. Use the three-arg overload to override + * the status explicitly when a call site needs a status the code alone does not imply. + */ + public static Response error(String error, String description) { + return error(error, description, statusForError(error)); + } + + public static Response error(String error, String description, Response.Status status) { + final Map errorMap = new HashMap<>(); + errorMap.put("error", error); + errorMap.put("error_description", description); + return Response.status(status).entity(JsonUtils.renderAsJsonString(errorMap)).build(); + } + + private static Response.Status statusForError(String error) { + if (error == null) { + return Response.Status.BAD_REQUEST; + } + switch (error) { + case "invalid_client": + return Response.Status.UNAUTHORIZED; // 401 + case "access_denied": + return Response.Status.FORBIDDEN; // 403 + case "server_error": + return Response.Status.INTERNAL_SERVER_ERROR; // 500 + case "temporarily_unavailable": + return Response.Status.SERVICE_UNAVAILABLE; // 503 + default: + // invalid_request, invalid_grant, invalid_scope, unsupported_grant_type, + // unsupported_response_type, unauthorized_client are all 400s. + return Response.Status.BAD_REQUEST; // 400 + } + } + + public static String getRequestParamSafe(final HttpServletRequest request, final String key) { + 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); + // Copy DEFAULT_SCOPES into a mutable set: the constant is now an ImmutableSet, and callers + // downstream may add/remove scopes on the returned set. + final Set requestedScopes = StringUtils.isBlank(scope) ? new HashSet<>(KnoxIDFConstants.DEFAULT_SCOPES) : new HashSet<>(Arrays.asList(scope.split("\\s+"))); + final String state = request.getParameter(KnoxIDFConstants.STATE); + final String nonce = request.getParameter(KnoxIDFConstants.NONCE); + final String codeChallenge = request.getParameter(KnoxIDFConstants.CODE_CHALLENGE); + 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, final String nonce) { + // URL-encode every value placed into the query string. client_id and the callback URI + // (which itself contains ':' '/' '?' etc.), the state and the nonce must be percent-encoded + // or the OP receives a malformed/parameter-split URL. CODE_RESPONSE_TYPE and OPENID_SCOPE are + // fixed "key=value" literals with no reserved characters, so they are appended as-is. + // The nonce binds the returned id_token to this authorization request (OIDC Core 3.1.2.1); + // it is verified against the id_token's nonce claim when the OP callback is processed. + return federatedOpConfiguration.getAuthorizeEndpoint() + + "?" + KnoxIDFConstants.CLIENT_ID + "=" + urlEncode(federatedOpConfiguration.getClientId()) + + "&" + KnoxIDFConstants.REDIRECT_URI + "=" + urlEncode(federatedOpConfiguration.getAuthorizeCallback()) + + "&" + KnoxIDFConstants.CODE_RESPONSE_TYPE + + "&" + KnoxIDFConstants.OPENID_SCOPE + + "&" + KnoxIDFConstants.STATE + "=" + urlEncode(federatedState) + + "&" + KnoxIDFConstants.NONCE + "=" + urlEncode(nonce); + } + + private static String urlEncode(final String value) { + return value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8); + } + +} diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java new file mode 100644 index 0000000000..8d1b87e549 --- /dev/null +++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java @@ -0,0 +1,99 @@ +/* + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import javax.servlet.ServletContext; + +import org.easymock.EasyMock; +import org.junit.Test; + +public class FederatedOpConfigurationTest { + + private static final String OP = "keycloak"; + private static final String PREFIX = KnoxIDFConstants.FEDERATED_OP_CONFIG_PREFIX + OP + "."; + + @Test + public void testIdTokenVerificationParamsAreRead() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getInitParameter(PREFIX + "jwks.endpoint")).andReturn("https://op.example/jwks").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "issuer")).andReturn("https://op.example/realms/knox").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "signature.algorithm")).andReturn("RS512").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "clientId")).andReturn("knox-client").anyTimes(); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + assertEquals("https://op.example/jwks", config.getJwksEndpoint()); + assertEquals("https://op.example/realms/knox", config.getIssuer()); + assertEquals("RS512", config.getSignatureAlgorithm()); + assertEquals("knox-client", config.getClientId()); + } + + @Test + public void testSignatureAlgorithmDefaultsToRS256() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + // No signature.algorithm configured -> the default (RS256) must be used. + EasyMock.expect(context.getInitParameter(PREFIX + "signature.algorithm")).andReturn(null).anyTimes(); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + assertEquals(FederatedOpConfiguration.DEFAULT_SIGNATURE_ALGORITHM, config.getSignatureAlgorithm()); + assertEquals("RS256", config.getSignatureAlgorithm()); + } + + @Test + public void testClientSecretAliasIsRead() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getInitParameter(PREFIX + "clientSecret")).andReturn("plaintext-secret").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "clientSecret.alias")).andReturn("keycloak-op-secret").anyTimes(); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + // Both are exposed; AuthorizeResource#resolveClientSecret decides precedence (alias wins). + assertEquals("plaintext-secret", config.getClientSecret()); + assertEquals("keycloak-op-secret", config.getClientSecretAlias()); + } + + @Test + public void testClientSecretAliasAbsentByDefault() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + // No alias configured -> resolveClientSecret falls back to the plaintext clientSecret param. + assertNull(config.getClientSecretAlias()); + } + + @Test + public void testVerificationParamsAbsentByDefault() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + // When nothing is configured the id_token verification inputs are null, which makes the + // federated login fail closed in AuthorizeResource#validateFederatedIdToken. + assertNull(config.getJwksEndpoint()); + assertNull(config.getIssuer()); + } +} diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java new file mode 100644 index 0000000000..04dbb9e845 --- /dev/null +++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFArtifactStoreTest.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +/** + * Verifies the {@link KnoxIDFArtifactStore#remove(String)} single-use invalidation added so an + * artifact (e.g. an authorize/consent state) cannot be replayed within its TTL grace window. + */ +public class KnoxIDFArtifactStoreTest { + + /** Minimal concrete store; the base class is abstract. TTL is large so nothing expires mid-test. */ + private static final class TestStore extends KnoxIDFArtifactStore { + TestStore() { + super(60_000L); + } + } + + @Test + public void testPutThenGetReturnsValue() { + final TestStore store = new TestStore(); + store.put("k", "v"); + assertEquals("v", store.get("k")); + } + + @Test + public void testRemoveInvalidatesEntry() { + final TestStore store = new TestStore(); + store.put("state", "payload"); + store.remove("state"); + assertNull("A removed entry must not be retrievable (single-use replay guard).", store.get("state")); + } + + @Test + public void testRemoveIsIdempotentAndSafeForUnknownKey() { + final TestStore store = new TestStore(); + store.remove("never-put"); // must not throw + assertNull(store.get("never-put")); + } + + @Test + public void testGetUnknownKeyReturnsNull() { + assertNull(new TestStore().get("missing")); + } +} diff --git a/knox-site/docs/assets/images/knoxidf/architecture.png b/knox-site/docs/assets/images/knoxidf/architecture.png new file mode 100644 index 0000000000..38294a7d47 Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/architecture.png differ diff --git a/knox-site/docs/assets/images/knoxidf/consent_page.png b/knox-site/docs/assets/images/knoxidf/consent_page.png new file mode 100644 index 0000000000..51b552dc45 Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/consent_page.png differ diff --git a/knox-site/docs/assets/images/knoxidf/login_page_federated.png b/knox-site/docs/assets/images/knoxidf/login_page_federated.png new file mode 100644 index 0000000000..eac3689d8b Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/login_page_federated.png differ diff --git a/knox-site/docs/assets/images/knoxidf/polaris_console_home.png b/knox-site/docs/assets/images/knoxidf/polaris_console_home.png new file mode 100644 index 0000000000..61d5501c53 Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/polaris_console_home.png differ diff --git a/knox-site/docs/assets/images/knoxidf/polaris_console_login.png b/knox-site/docs/assets/images/knoxidf/polaris_console_login.png new file mode 100644 index 0000000000..17084f379e Binary files /dev/null and b/knox-site/docs/assets/images/knoxidf/polaris_console_login.png differ diff --git a/knox-site/docs/knoxidf/configuration.md b/knox-site/docs/knoxidf/configuration.md new file mode 100644 index 0000000000..9d02e864bb --- /dev/null +++ b/knox-site/docs/knoxidf/configuration.md @@ -0,0 +1,188 @@ + + +# Configuration Reference + +KnoxIDF is configured through two layers: + +- **Topology `KNOXIDF` service parameters** — per-deployment behavior (token TTLs, consent, + federation, user attributes). These live inside the `KNOXIDF` + element of a topology file. +- **`gateway-site.xml` properties** — gateway-wide concerns shared with the rest of Knox (signing + keys, persistence/database, trusted-issuer cache tuning). + +This chapter lists every parameter, its default, and its effect. + +## How KNOXIDF service parameters are read + +### The `knoxidf.` → `knox.token.` prefix passthrough + +KnoxIDF reuses Knox's existing server-managed token machinery. Any `KNOXIDF` service parameter +written with the **`knoxidf.` prefix** has that prefix stripped and the remainder passed through +to the underlying token configuration. So the topology parameter: + +```xml + + knoxidf.knox.token.ttl + 86400000 + +``` + +is delivered to the token layer as `knox.token.ttl`. This lets you set any +[KnoxToken](../config_knox_token.md) parameter on the KnoxIDF service by prefixing it with +`knoxidf.`. + +!!! note "Access-token TTL is managed by Knox" + KnoxIDF issues **server-managed** tokens: the access-token lifetime is governed by the token + layer (`knoxidf.knox.token.ttl`) and, on the token-exchange topology, by the `JWTProvider`'s + `knox.token.exp.server-managed=true`. Clients cannot request an arbitrary lifetime. + +## Core `KNOXIDF` service parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `knoxidf.knox.token.ttl` | (token-layer default) | Access-token lifetime in **milliseconds** (passthrough to `knox.token.ttl`). | +| `knoxidf.knox.token.limit.per.user` | (token-layer default) | Max concurrent server-managed tokens per user; `-1` = unlimited (passthrough to `knox.token.limit.per.user`). | +| `refresh.token.ttl` | `86400000` (24 h) | Refresh-token lifetime in milliseconds. Refresh tokens are issued only when the `offline_access` scope is granted, and are rotated on each use. | +| `knoxidf.auto.consent.enabled` | `false` | When `true`, the [consent screen](security.md#consent) is skipped. This is a **server-side** decision and is never read from a client request parameter. | +| `knoxidf.client.registration.anonymous.allowed` | `false` | When `true`, [dynamic client registration](security.md#dynamic-client-registration) accepts anonymous callers. Secure by default. | +| `knoxidf.registration.allowed.scopes` | OIDC-standard set | Comma-separated server-side whitelist of scopes a client may put in its `allowed_scopes` at [registration](security.md#registerable-scope-whitelist). An explicit value is **authoritative** (replaces the default). `openid` is always registerable. Blank/unset ⇒ `openid,profile,email,address,phone,offline_access`. | +| `token.exchange.topology.name` | (none) | Name of the token-exchange topology (fronted by `JWTProvider`) to which the `token_endpoint` and `userinfo_endpoint` are redirected in discovery. See the [two-topology model](getting_started.md#3-deploy-the-knoxidf-topologies). | +| `federated.op.names` | (none) | Comma-separated list of federated OP logical names to enable. See [Federated OP parameters](#federated-op-parameters). | + +!!! warning "Secure-by-default flags" + Both `knoxidf.auto.consent.enabled` and `knoxidf.client.registration.anonymous.allowed` + default to **`false`**. The sample topologies set them to `true` only to keep experimentation + frictionless — review them before any non-development deployment. + +## Federated OP parameters + +Each name listed in `federated.op.names` is configured with a block of +`federated.op..` parameters. See [Federation](federation.md) for the full flow and a +complete example. + +| Suffix | Required | Default | Description | +|--------|----------|---------|-------------| +| `enabled` | — | `false` | Activates this OP. Only enabled OPs are offered on the login page. | +| `issuer` | **Yes** | — | Expected `iss` of the OP's id_token. Validated exactly — must match the OP's issuer. | +| `jwks.endpoint` | **Yes** | — | OP JWKS URL used to verify the id_token signature. | +| `clientId` | Yes | — | Knox's client id at the OP; must appear in the id_token `aud`. | +| `clientSecret` | Conditional | — | Knox's client secret at the OP (plaintext). Prefer `clientSecret.alias`. | +| `clientSecret.alias` | Conditional | — | Alias name resolved via `AliasService`. **Takes precedence** over `clientSecret` and **fails closed** if unresolvable. See [Security → Secret handling](security.md#secret-handling). | +| `authorize.endpoint` | Yes | — | OP authorization endpoint Knox redirects the user to. | +| `token.endpoint` | Yes | — | OP token endpoint for the back-channel code exchange. | +| `userinfo.endpoint` | No | — | OP UserInfo endpoint. | +| `discovery.endpoint` | No | — | OP discovery document URL (alternative to listing endpoints individually). | +| `authorize.callback` | Yes | — | The Knox callback URL registered at the OP (`…/knoxidf/api/v1/authorize/callback`). | +| `signature.algorithm` | No | `RS256` | Expected id_token signing algorithm. | + +## User parameters and claims + +KnoxIDF can enrich issued tokens with additional claims — statically configured claims and, for +local users, attributes resolved from a **user-parameter provider**. + +### Hard-coded claim mappings + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `knox.token.hardcoded.claim.mappings` | (none) | `;`-separated list of `key=value` pairs added as claims to every issued token. | + +### LDAP user-parameter provider + +If `user.params.provider.ldap.url` is set, KnoxIDF looks up the authenticated user in LDAP and adds +the resolved attributes to the token (and to the UserInfo response). If it is **absent**, an +`EmptyUserParamsProvider` is used and no LDAP lookup occurs. + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `user.params.provider.ldap.url` | (none) | LDAP(S) URL. Its presence selects the LDAP provider; absence selects the no-op provider. | +| `user.params.provider.ldap.baseDn` | `dc=hadoop,dc=apache,dc=org` | Search base DN. | +| `user.params.provider.ldap.userDnTemplate` | `uid=%s,ou=people,dc=hadoop,dc=apache,dc=org` | DN template; `%s` is replaced with the (escaped) username. | +| `user.params.provider.ldap.systemUser` | `uid=admin,ou=people,dc=hadoop,dc=apache,dc=org` | Bind DN for attribute lookups. | +| `user.params.provider.ldap.systemPasswordAlias` | — | **Required** alias for the system-user password. There is no plaintext fallback — if the alias is absent or unresolvable, initialization fails with an `IllegalStateException` (fail-closed). | + +## Gateway-site properties + +These are set in `$KNOX_HOME/conf/gateway-site.xml` and shared with the rest of Knox. + +### Signing keys + +| Property | Default | Description | +|----------|---------|-------------| +| `gateway.signing.key.alias` | `gateway-identity` | Alias of the primary key used to sign KnoxIDF-issued JWTs. | +| `gateway.signing.key.aliases.additional` | `none` | Comma-separated additional signing-key aliases to **also publish** on the JWKS endpoint. This is the mechanism behind zero-downtime [signing-key rotation](operations.md#signing-key-rotation). `none` means no additional keys. | +| `gateway.signing.keystore.name` | (gateway identity keystore) | Keystore holding the signing key(s). | +| `gateway.signing.keystore.type` | (gateway default) | Keystore type (e.g. `JKS`, `PKCS12`). | +| `gateway.signing.keystore.password.alias` | (gateway default) | Alias of the keystore password. | + +### Persistence and database + +KnoxIDF's [federated-identity store](operations.md#federated-identity-persistence) and the trusted-issuer +registry share Knox's database configuration. With no external database configured, KnoxIDF +self-provisions an **embedded Derby** database (the same physical store used by token state), so no +setup is required to get started. + +| Property | Default | Description | +|----------|---------|-------------| +| `gateway.database.type` | `none` | Database backend: `none` / `derbydb` select the embedded self-provisioning Derby store; a real external type (`postgresql`, `mysql`, `oracle`, …) selects the JDBC-backed store. | +| `gateway.database.connection.url` | (none) | Full JDBC URL (overrides host/port/name if set). | +| `gateway.database.host` | (none) | Database host (when not using a full connection URL). | +| `gateway.database.port` | (none) | Database port. | +| `gateway.database.name` | `GATEWAY_DATABASE` | Database/schema name. | +| `gateway.database.ssl.enabled` | `false` | Enable TLS to the database. | +| `gateway.database.ssl.truststore.path` / `.alias` | (none) | Truststore path / password alias for the database TLS connection. | + +Database credentials are supplied as aliases (`gateway_database_user`, +`gateway_database_password`) — see [Getting Started](getting_started.md#2-install-and-start-knox). + +### Trusted OIDC issuer registry + +| Property | Default | Description | +|----------|---------|-------------| +| `gateway.trustedoidcissuer.discovery.cache.ttl.secs` | `600` | How long a fetched issuer JWKS/discovery document is cached before re-fetch. | +| `gateway.trustedoidcissuer.discovery.connect.timeout.ms` | `3000` | Connect timeout when fetching an issuer's discovery/JWKS document. | +| `gateway.trustedoidcissuer.discovery.read.timeout.ms` | `10000` | Read timeout for the same fetch. | +| `gateway.trusted.oidc.issuer.max.issuers` | `10000` | Upper bound on the number of registered trusted issuers. Registration returns `409 issuer_limit_reached` once reached. | + +### Federated OP back-channel + +Timeouts for the HTTP client KnoxIDF uses for the [back-channel token exchange](federation.md) +against an external OP's `token.endpoint`. Without them an unresponsive OP endpoint would pin the +calling request thread indefinitely, so enough hung federated logins could exhaust the gateway's +request threads. + +| Property | Default | Description | +|----------|---------|-------------| +| `gateway.knoxidf.federated.op.connect.timeout.ms` | `3000` | Connect timeout (also used as the connection-pool request timeout) for the federated-OP token-exchange call. | +| `gateway.knoxidf.federated.op.read.timeout.ms` | `10000` | Socket/read timeout for the same call. | + +### Provider-related properties (sample topologies) + +These are not KnoxIDF parameters but appear in the sample federation topologies: + +| Property | Default | Description | +|----------|---------|-------------| +| `jwt.expected.issuer` | — | Expected issuer enforced by a `JWTProvider` fronting the token-exchange topology. | +| `knox.token.exchange.dynamic.jwks.allow.http` | `false` | On a `JWTProvider` fronting the token-exchange topology, whether a JWKS URI resolved from a trusted issuer's discovery document may use plain HTTP. Defaults to `false` (HTTPS enforced); a non-HTTPS dynamic JWKS URI is rejected and the exchange fails with `401`. Set to `true` only for development against an HTTP issuer. | +| `sso.unauthenticated.path.list` | — | On an `SSOCookieProvider` front topology, the `;`-separated list of KnoxIDF paths reachable before login (callback, JWKS, discovery, registration). See [Federation](federation.md#front-topology-for-federation). | + +## See also + +- [Getting Started](getting_started.md) — worked topology examples. +- [Security](security.md) — what the secure-by-default flags protect against. +- [Federation](federation.md) — configuring external OPs. +- [Operations](operations.md) — persistence backends and signing-key rotation. diff --git a/knox-site/docs/knoxidf/endpoints.md b/knox-site/docs/knoxidf/endpoints.md new file mode 100644 index 0000000000..269142fa79 --- /dev/null +++ b/knox-site/docs/knoxidf/endpoints.md @@ -0,0 +1,306 @@ + + +# Endpoint Reference + +KnoxIDF exposes a set of REST endpoints aligned with the standard OpenID Connect expectations, +so clients configured for a Keycloak-like provider work against Knox with minimal changes. + +## Base path and URL structure + +All KnoxIDF endpoints share the base path `knoxidf/api/v1`. As with every Knox service, the +gateway prefixes the topology name, so a fully-qualified URL looks like: + +``` +https://{knox-host}:8443/gateway/{topology}/knoxidf/api/v1/{endpoint} +``` + +The administrative endpoints (service role `KNOXIDF_ADMIN`) live under a separate base path, +`knoxidf/admin/v1`. + +!!! tip "Always read endpoint URLs from discovery" + Do not hard-code endpoint paths in clients. Fetch the + [discovery document](#discovery-endpoint) and use the URLs it advertises. When a + `token.exchange.topology.name` is configured, the `token_endpoint` and `userinfo_endpoint` + are deliberately rewritten to point at the token-exchange topology — discovery reflects + that, hard-coded paths will not. + +## Endpoint summary + +| Endpoint | Path | Methods | Role | +|----------|------|---------|------| +| [Discovery](#discovery-endpoint) | `knoxidf/api/v1/.well-known/openid-configuration` | GET | `KNOXIDF` | +| [Authorization](#authorization-endpoint) | `knoxidf/api/v1/authorize` | GET, POST | `KNOXIDF` | +| [Federated callback](#federated-callback) | `knoxidf/api/v1/authorize/callback` | GET | `KNOXIDF` | +| [Token](#token-endpoint) | `knoxidf/api/v1/token` | POST | `KNOXIDF` | +| [UserInfo](#userinfo-endpoint) | `knoxidf/api/v1/userinfo` | GET | `KNOXIDF` | +| [JWKS](#jwks-endpoint) | `knoxidf/api/v1/jwks` | GET | `KNOXIDF` | +| [Client Registration](#client-registration-endpoint) | `knoxidf/api/v1/client/register` | POST | `KNOXIDF` | +| [Consent page](#consent-page) | `authConsent` | GET | `KNOXIDF` | +| [Consent decision](#consent-page) | `knoxidf/api/v1/authorize/consentAccepted`, `…/consentDenied` | POST | `KNOXIDF` | +| [Trusted OIDC Issuers (admin)](#trusted-oidc-issuers-admin) | `knoxidf/admin/v1/trusted-oidc-issuers` | GET, POST, DELETE | `KNOXIDF_ADMIN` | + +--- + +## Discovery endpoint + +`GET /knoxidf/api/v1/.well-known/openid-configuration` + +Returns the OpenID Connect Discovery document. All endpoint URLs are built dynamically from the +request's base URI, so the document is always correct for the topology it is served from. + +Example document: + +```json +{ + "issuer": "https://knox:8443/gateway/knoxidf-ldap/knoxidf", + "authorization_endpoint": "https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/authorize", + "token_endpoint": "https://knox:8443/gateway/knoxidf-token/knoxidf/api/v1/token", + "userinfo_endpoint": "https://knox:8443/gateway/knoxidf-token/knoxidf/api/v1/userinfo", + "registration_endpoint": "https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/client", + "jwks_uri": "https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/jwks", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "none"], + "client_id_metadata_document_supported": false, + "grant_types_supported": ["authorization_code", "refresh_token"], + "scopes_supported": ["openid", "profile", "email", "offline_access"], + "id_token_signing_alg_values_supported": ["RS256"], + "code_challenge_methods_supported": ["S256"] +} +``` + +Notable metadata: + +- **`subject_types_supported: ["public"]`** — Knox derives a shared (non-pairwise) `sub`, the + same value for every client. +- **`token_endpoint_auth_methods_supported: ["client_secret_post", "none"]`** — the token + endpoint reads client credentials only from the request body (`client_secret_post`); + public clients use PKCE with no secret (`none`). HTTP Basic (`client_secret_basic`) is + intentionally **not** advertised because it is not honored. +- **`code_challenge_methods_supported: ["S256"]`** — only S256 PKCE is accepted; `plain` is + rejected. +- **`client_id_metadata_document_supported: false`** — Knox does not resolve a URL-style + `client_id` as a Client ID Metadata Document (OAuth CIMD draft, referenced by the MCP + authorization spec); clients must use dynamic registration instead. + +--- + +## Authorization endpoint + +`GET|POST /knoxidf/api/v1/authorize` + +Begins the Authorization Code flow. Validates the request, checks (or collects) user consent, +and redirects back to the client `redirect_uri` with an authorization `code` and the echoed +`state`. If consent has not yet been granted for this (user, client, scopes), the browser is +first redirected to the [consent page](#consent-page). + +Request parameters: + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `response_type` | Yes | `code`, `id_token`, or `code id_token`. | +| `client_id` | Yes | A registered client identifier. | +| `redirect_uri` | Yes | Must match the client's registered `redirect_uris`. | +| `scope` | No | Space-delimited scopes. Defaults to `openid profile email offline_access`. | +| `state` | No | Opaque CSRF value, echoed back in the redirect. | +| `nonce` | No | Bound to the issued ID token. | +| `code_challenge` | No (PKCE) | Base64url S256 hash of the `code_verifier`. | +| `code_challenge_method` | Required if `code_challenge` present | Must be `S256`; `plain` is rejected. | + +**Success:** `302` redirect to `{redirect_uri}?code={auth_code}&state={state}`. + +**Errors:** a JSON body `{"error": "...", "error_description": "..."}` — `invalid_request` +(unknown `client_id`, bad `redirect_uri`, missing parameters, unsupported PKCE method) or +`invalid_scope` (a requested scope is not in the client's allowed scopes). When consent is +required, a `303` redirect to the consent page. + +### Federated callback + +`GET /knoxidf/api/v1/authorize/callback` + +Back-channel callback invoked by an external OIDC Provider during [federation](federation.md). +Exchanges the OP authorization `code` for the OP's tokens, **validates the OP `id_token`** +(signature via JWKS, issuer, audience, nonce, and `sub` presence), resolves or persists the +federated identity, then issues a Knox authorization code and redirects to the original client +`redirect_uri`. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `code` | Yes | Authorization code from the federated OP. | +| `state` | Yes | Must match a live entry in the authorize-request store. | + +This endpoint must be reachable without prior Knox authentication (wired as `anon`, or listed +in `sso.unauthenticated.path.list`). + +--- + +## Token endpoint + +`POST /knoxidf/api/v1/token`   `Content-Type: application/x-www-form-urlencoded` + +Issues tokens. Supports the **Client Credentials**, **Authorization Code**, and **Refresh +Token** grants. Client authentication is enforced here — see +[Security](security.md#token-endpoint-client-authentication). + +### Authorization Code grant + +Redeems a one-time authorization code. The code is atomically consumed (single-use); a public +client proves possession via PKCE `code_verifier`, a confidential client via `client_secret`. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `grant_type` | Yes | `authorization_code`. | +| `code` | Yes | The authorization code from `/authorize`. | +| `redirect_uri` | Yes | Must match the URI stored with the code. | +| `client_id` | Yes | Must match the client that obtained the code. | +| `client_secret` | Conditional | Required when no `code_challenge` was stored. | +| `code_verifier` | Conditional | Required when a `code_challenge` was stored at authorize time. | + +### Refresh Token grant + +Rotates a refresh token: atomically consumes the presented token and issues a new +access-token / refresh-token pair. Requires `client_secret`. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `grant_type` | Yes | `refresh_token`. | +| `refresh_token` | Yes | A previously issued refresh token. | +| `client_id` | Yes | Must match the client bound to the refresh token. | +| `client_secret` | Yes | Client secret. | + +**Success (`200`):** + +```json +{ + "access_token": "", + "token_id": "", + "token_type": "Bearer", + "expires_in": 1699999999999, + "managed_token": "true", + "id_token": "", + "refresh_token": "", + "passcode": "" +} +``` + +`refresh_token` is present only when the scope includes `offline_access`. The `id_token` +carries `sub`, `iss`, `aud` (= `client_id`), `exp`, `iat`, and `nonce` (when supplied); for +federated users it additionally carries `federated_idp`, `federated_sub`, and `federated_iss` +plus any allowed profile claims (`preferred_username`, `email`, `email_verified`, +`given_name`, `family_name`, `name`, `locale`). + +**Errors:** `invalid_grant` (missing/expired/replayed code, `redirect_uri` or `client_id` +mismatch, PKCE failure, bad `client_secret`, disabled/expired refresh token) or +`invalid_request` (unsupported `grant_type`). + +--- + +## UserInfo endpoint + +`GET /knoxidf/api/v1/userinfo` + +Returns OIDC UserInfo claims for a valid bearer access token. The upstream `JWTProvider` +validates the token and hands the token identity to this resource; the endpoint never reads the +raw `Authorization` header itself. For a federated user it returns the internal Knox `sub`, the +`idp` name, `federated_sub`, `federated_iss`, and any allowed profile claims; for a local user +it returns whatever the configured [user-parameter provider](configuration.md#user-parameters-and-claims) +resolves. + +**Errors:** `invalid_request` when no token identity is present; `401` with +`WWW-Authenticate: Bearer error="invalid_token"` for an expired, revoked, or unknown token. + +--- + +## JWKS endpoint + +`GET /knoxidf/api/v1/jwks` + +Publishes the gateway's public signing key(s) as a JWK Set so clients and resource servers can +verify KnoxIDF-issued JWTs. One JWK is published per configured signing-key alias, each keyed by +the SHA-256 thumbprint of its public key as the `kid`. This is what makes signing-key rotation +transparent to verifiers — see [Operations → Signing-key rotation](operations.md#signing-key-rotation). + +```json +{ + "keys": [ + { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "", "n": "...", "e": "AQAB" } + ] +} +``` + +--- + +## Client Registration endpoint + +`POST /knoxidf/api/v1/client/register`   `Content-Type: application/x-www-form-urlencoded` + +Dynamically registers an OAuth2 client and returns a `client_id` and `client_secret`. Redirect +URIs must use HTTPS (plain HTTP is allowed only for loopback hosts, per RFC 8252). Anonymous +registration is **refused by default** and only permitted when +`knoxidf.client.registration.anonymous.allowed=true` is set on the topology — see +[Security](security.md#dynamic-client-registration). + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `redirect_uris` | Yes | Comma-separated. HTTPS required (loopback HTTP allowed); a wildcard `*` is only permitted at the end of the path, never in the host. | +| `allowed_scopes` | No | Comma-separated; must include `openid`. Defaults to `openid,profile,email,offline_access`. | + +**Success (`200`):** returns `token_id` (the `client_id`), `passcode` (the `client_secret` to +use on `/token`), and the stored `redirect_uris` and `allowed_scopes`. + +**Errors:** `access_denied` (anonymous caller when disabled), `invalid_request` (missing/invalid +`redirect_uris`, wrong scheme), `invalid_scope` (`allowed_scopes` omits `openid`). + +--- + +## Consent page + +`GET /{topology}/authConsent` + +An HTML consent page (a lightweight servlet, registered only when the topology includes the +`KNOXIDF` service). `GET` renders the requesting `client_id` and a human-readable description of +each requested scope with **Accept** / **Deny** buttons. + +The decision is submitted with an HTTP **POST** — the **Accept** and **Deny** buttons post the +form directly to `authorize/consentAccepted` and `authorize/consentDenied` respectively. Both +endpoints are **POST-only**, so accepting consent (which persists a consent record and issues an +authorization code) can never be triggered by passive `GET` navigation such as link prefetch, +history re-navigation, or a leaked consent-URL. `consentAccepted` also verifies that the +authenticated user matches the subject that initiated the authorization request, rejecting a +replayed consent form from a different user with `403`; `consentDenied` returns `403`. Consent is +one-time per (user, client, scopes) — see [Security → Consent](security.md#consent). + +![The KnoxIDF consent page: "Application Consent Required", listing the requesting client and the scopes it will be granted, with Accept and Deny buttons.](../assets/images/knoxidf/consent_page.png) + +--- + +## Trusted OIDC Issuers (admin) + +Base path `knoxidf/admin/v1/trusted-oidc-issuers`, served by the `KNOXIDF_ADMIN` service role +(a separate, administrator-only topology). Manages the set of external issuers whose `id_token`s +Knox will accept during federated login. + +| Method | Path | Purpose | Success | +|--------|------|---------|---------| +| `POST` | `/trusted-oidc-issuers` | Register a trusted issuer (JSON body: `issuerUrl` (HTTPS, required), `dynamicJwks`, `clusterName`). | `201` | +| `GET` | `/trusted-oidc-issuers` | List registered issuers (with `registeredAt` / `registeredBy`). | `200` | +| `DELETE` | `/trusted-oidc-issuers?issuerUrl=...` | Deregister an issuer (idempotent). | `204` | +| `POST` | `/trusted-oidc-issuers/refresh-jwks?issuerUrl=...` | Force a JWKS cache refresh for a `dynamicJwks` issuer. | `204` | + +**Errors:** `400 invalid_request` (missing/non-HTTPS `issuerUrl`, malformed JSON), +`409 issuer_exists`, `409 issuer_limit_reached` (issuer cap), `500 storage_error`. diff --git a/knox-site/docs/knoxidf/federation.md b/knox-site/docs/knoxidf/federation.md new file mode 100644 index 0000000000..ad17d74c6d --- /dev/null +++ b/knox-site/docs/knoxidf/federation.md @@ -0,0 +1,221 @@ + + +# Federation + +In addition to being a standalone OIDC Provider, KnoxIDF can **broker** login to one or more +external OpenID Providers (OPs) — Keycloak, Okta, Azure AD, Auth0, and so on. In this mode Knox +delegates the actual authentication to the external OP, validates the identity it returns, and +then re-issues **its own Knox-signed tokens** to the client. Downstream services still only need +to trust Knox, regardless of where the user actually authenticated. + +Federation is entirely optional and configured per topology. A topology with no +`federated.op.names` behaves as a pure Knox OP. + +## The login experience + +When a topology fronts `/authorize` with an SSO cookie provider and has one or more federated +OPs enabled, the Knox login page offers the external OP as an alternative to Knox's own +authentication providers (LDAP, PAM, SAML, Kerberos, …): + +![The Knox login page showing username/password fields and a "Continue with KeyCloak" button below an "Or" separator.](../assets/images/knoxidf/login_page_federated.png) + +The end user chooses whether to sign in with a Knox-native provider or to identify themselves +through the external OIDC Provider. + +## Broker flow + +Federation is implemented as a token-brokering mechanism: + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Knox as Knox (KnoxIDF) + participant OP as External OP + Client->>Knox: GET /authorize (response_type=code, PKCE) + Note over Knox: Authenticate request, validate params, check consent + Knox->>OP: Redirect to OP /authorize (nonce, callback) + OP-->>Client: Prompt for login + Client->>OP: Authenticate + OP->>Knox: GET /authorize/callback (code, state) + Note over Knox: Exchange code at OP /token (back-channel) + Knox->>OP: POST /token (federated code, client_secret) + OP-->>Knox: OP tokens (id_token) + Note over Knox: Validate id_token (sig via JWKS, iss, aud, nonce, sub) + Note over Knox: Resolve / persist federated identity + Knox-->>Client: Redirect to redirect_uri (Knox code, state) + Client->>Knox: POST /token (code + client_secret / code_verifier) + Knox-->>Client: Knox access_token + id_token (+ refresh_token) +``` + +Step by step: + +1. **Client initiates.** The OIDC client calls `/authorize`. The topology's provider + authenticates the request and Knox validates the parameters and checks consent. +2. **Delegate to the OP.** With a federated OP enabled, Knox builds an authorization redirect to + the OP's `authorize.endpoint`, including a freshly generated `nonce` (stored server-side for + this session) and the callback URL `/knoxidf/api/v1/authorize/callback`. +3. **The OP authenticates the user** and redirects back to Knox's callback with an authorization + `code` and the `state`. +4. **Back-channel token exchange.** Knox exchanges the OP code at the OP's `token.endpoint`, + resolving the OP `client_secret` (from an [alias](security.md#secret-handling) if configured). +5. **Validate the OP `id_token`.** Knox verifies the signature (via the OP's JWKS), the issuer, + the audience, the `nonce`, and the presence of `sub` — see + [Security → Federated id_token validation](security.md#federated-id_token-validation). Nothing + in the token is trusted before this passes. +6. **Resolve or persist the federated identity.** Knox looks up `(provider, issuer, subject)`; + if not found, it persists a new federated identity, deriving a stable Knox `sub` as a + [UUIDv5](security.md#subject-derivation) over the OP issuer and subject. +7. **Issue a Knox authorization code**, then redirect the client to its `redirect_uri`. +8. **Client redeems the code** at Knox's `/token` endpoint (with PKCE or `client_secret`) and + receives Knox-signed tokens whose `id_token` carries the federated claims below. + +## Federated claims in the Knox id_token + +For a federated user, the Knox-issued `id_token` (and the UserInfo response) carry the origin of +the identity alongside the Knox subject: + +| Claim | Meaning | Example | +|-------|---------|---------| +| `sub` | Stable Knox subject (UUIDv5 over issuer + external subject). | `f47ac10b-58cc-45c8-...` | +| `federated_idp` | The federated provider name, **upper-cased**. | `KEYCLOAK` | +| `federated_sub` | The `sub` from the OP's id_token. | `248289761001` | +| `federated_iss` | The `iss` from the OP's id_token. | `https://op.example/realms/knox` | + +Allowed profile claims (`preferred_username`, `email`, `email_verified`, `given_name`, +`family_name`, `name`, `locale`) are included when present. The UserInfo response uses `idp` for +the provider name (also upper-cased) in place of `federated_idp`. + +!!! note + Because `federated_idp` and the stored provider are upper-cased, a configured OP name of + `keycloak` appears in tokens as `KEYCLOAK`. Match on the upper-cased value in downstream + authorization logic. + +## Configuring a federated OP + +Federated OPs are declared as `KNOXIDF` service parameters so they are exposed as servlet +context init-params (read by both `AuthorizeResource` and the SSO cookie federation filter in +the same webapp). First list the OP logical names, then provide a block of +`federated.op..*` parameters for each. The example below is the tested CI topology for a +Keycloak OP: + +```xml + + KNOXIDF + + + + federated.op.names + keycloak + + + federated.op.keycloak.enabled + true + + + federated.op.keycloak.clientId + knox-client + + + federated.op.keycloak.clientSecret + knox-client-secret + + + federated.op.keycloak.authorize.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/auth + + + federated.op.keycloak.authorize.callback + https://knox:8443/gateway/knoxidf-sso/knoxidf/api/v1/authorize/callback + + + federated.op.keycloak.token.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/token + + + federated.op.keycloak.jwks.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/certs + + + federated.op.keycloak.issuer + http://keycloak:8080/realms/knox + + + federated.op.keycloak.userinfo.endpoint + http://keycloak:8080/realms/knox/protocol/openid-connect/userinfo + + + federated.op.keycloak.signature.algorithm + RS256 + + +``` + +!!! warning "Prefer an alias for the OP client secret" + The example above uses a plaintext `clientSecret` for brevity. In production, store the + secret in Knox's credential store and reference it with + `federated.op.keycloak.clientSecret.alias` instead — the alias takes precedence and + resolution fails closed if it cannot be resolved. See + [Security → Secret handling](security.md#secret-handling). See the + [Configuration Reference](configuration.md#federated-op-parameters) for every + `federated.op..*` parameter. + +### Front topology for federation + +The federation login experience requires a topology that fronts `/authorize` with an +`SSOCookieProvider` (so an unauthenticated `/authorize` is redirected to the Knox login +front-end), with the federation callback and other pre-login endpoints listed in +`sso.unauthenticated.path.list`: + +```xml + + federation + SSOCookieProvider + true + + sso.authentication.provider.url + https://knox:8443/gateway/knoxsso/api/v1/websso + + + sso.unauthenticated.path.list + /knoxidf/api/v1/authorize/callback;/knoxidf/api/v1/jwks;/knoxidf/api/v1/.well-known/openid-configuration;/knoxidf/api/v1/client/register + + +``` + +## Multiple OPs + +`federated.op.names` accepts a comma-separated list, and each named OP gets its own +`federated.op..*` block. Only OPs with `enabled=true` are activated; the login page offers +each enabled OP as a separate sign-in option. + +## Trusted issuer registry + +The external issuers whose tokens Knox will accept are administered through the +[Trusted OIDC Issuers admin API](endpoints.md#trusted-oidc-issuers-admin), served by the +`KNOXIDF_ADMIN` role on an administrator-restricted topology. Issuer JWKS documents are cached +(`gateway.trustedoidcissuer.discovery.cache.ttl.secs`, default 600s); the admin API's +`refresh-jwks` action forces an immediate re-fetch for issuers configured with dynamic JWKS. + +## Persistence + +Federated identities are persisted so the same upstream user maps to a stable Knox subject and +so their attributes can be reused. This store activates automatically when a `KNOXIDF` (or +`KNOXIDF_ADMIN`) topology is present — no explicit configuration is required. See +[Operations → Federated identity persistence](operations.md#federated-identity-persistence) for +the backend-selection rules and how to point KnoxIDF at an external database. diff --git a/knox-site/docs/knoxidf/getting_started.md b/knox-site/docs/knoxidf/getting_started.md new file mode 100644 index 0000000000..7b17ff19c5 --- /dev/null +++ b/knox-site/docs/knoxidf/getting_started.md @@ -0,0 +1,284 @@ + + +# Getting Started + +This chapter walks through building Knox with KnoxIDF, deploying the topologies that expose +the OIDC endpoints, registering a client, and running a first Client Credentials flow. + +!!! note "Branch" + At the time of writing, KnoxIDF lives on the `knox_idf` development branch (kept in sync + with `master`). Build from that branch until it is merged. + +## 1. Build Knox + +```bash +git clone https://github.com/apache/knox.git +cd knox +git checkout knox_idf + +mvn -DskipTests -Dcheckstyle.skip=true -Dfindbugs.skip=true -Dpmd.skip=true \ + -Drat.skip -Dspotbugs.skip=true -Dforbiddenapis.skip=true \ + -Ppackage clean install +``` + +The build produces a Knox distribution archive under +`gateway-release/target/{version}/knox-{version}.zip`. + +## 2. Install and start Knox + +Unzip the distribution into a deployment directory (`$KNOX_HOME`), create the master secret +and the required aliases, then start the gateway. The signing-key hash alias +(`knox.token.hash.key`) backs server-managed (passcode) tokens; the database aliases back the +embedded/external persistence used by KnoxIDF and token state. + +```bash +export KNOX_HOME=/path/to/knoxGateway + +# Master secret (non-interactive) +$KNOX_HOME/bin/knoxcli.sh create-master --master gateway + +# Signing / passcode HMAC key +$KNOX_HOME/bin/knoxcli.sh create-alias knox.token.hash.key --value + +# Database credential aliases (used by the embedded Derby store and any external DB) +$KNOX_HOME/bin/knoxcli.sh create-alias gateway_database_user --value knox +$KNOX_HOME/bin/knoxcli.sh create-alias gateway_database_password --value knox + +$KNOX_HOME/bin/gateway.sh start +``` + +!!! tip "Local (non-TLS) testing" + For local experimentation you can disable TLS by setting `ssl.enabled=false` in + `$KNOX_HOME/conf/gateway-site.xml`. **Do not do this in production** — OAuth 2.0 / OIDC + requires TLS for all token-bearing traffic. + +By default, KnoxIDF's federated-identity persistence uses an **embedded Derby** database that +Knox provisions automatically (the same physical DB used by token state). No external database +is required to get started. To point KnoxIDF at an external database (PostgreSQL, etc.), see +the [Configuration Reference](configuration.md) and [Operations](operations.md) chapters. + +## 3. Deploy the KnoxIDF topologies + +A typical KnoxIDF deployment uses **two topologies**: + +- **A "front" topology** that exposes the OIDC endpoints and authenticates the end user (for + example with LDAP Basic auth, or with an SSO cookie provider for federation). This is where + `/authorize`, `/client/register`, `/jwks`, and discovery live. +- **A "token" topology** fronted by Knox's `JWTProvider`, referenced by the front topology's + `token.exchange.topology.name`. The `/token` exchange is redirected here so that redeeming an + authorization code is authenticated by a Knox-issued JWT. + +Copy the topology files into `$KNOX_HOME/conf/topologies/`; Knox hot-deploys them within a few +seconds. + +### Front topology (LDAP Basic auth) — `knoxidf-ldap.xml` + +The `ShiroProvider` authenticates users against LDAP, and the OIDC endpoints that must be +reachable *before* login (discovery, registration, JWKS, and the federation callback) are +wired as `anon`: + +```xml + + + + authentication + ShiroProvider + true + + main.ldapRealm + org.apache.knox.gateway.shirorealm.KnoxLdapRealm + + + main.ldapRealm.userDnTemplate + uid={0},ou=people,dc=hadoop,dc=apache,dc=org + + + main.ldapRealm.contextFactory.url + ldaps://localhost:33390 + + + main.ldapRealm.contextFactory.authenticationMechanism + simple + + + urls./knoxidf/api/v1/.well-known/openid-configuration + anon + + + urls./knoxidf/api/v1/client/register + anon + + + urls./knoxidf/api/v1/authorize/callback + anon + + + urls./knoxidf/api/v1/jwks + anon + + + urls./** + authcBasic + + + + identity-assertion + Default + true + + + + + KNOXIDF + + knoxidf.knox.token.ttl + 60000 + + + knoxidf.knox.token.limit.per.user + -1 + + + + knoxidf.client.registration.anonymous.allowed + true + + + + knoxidf.auto.consent.enabled + true + + + token.exchange.topology.name + knoxidf-token + + + +``` + +!!! warning "Anonymous client registration is opt-in" + `knoxidf.client.registration.anonymous.allowed` defaults to **`false`** (secure by + default). The sample above sets it to `true` only to keep the endpoint open for + experimentation. See [Security](security.md#dynamic-client-registration). + +### Token topology (`JWTProvider`) — `knoxidf-token.xml` + +```xml + + + + 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 + + + knoxidf.auto.consent.enabled + true + + + + KNOXTOKEN + + knox.token.ttl + 60000 + + + knox.token.limit.per.user + -1 + + + +``` + +## 4. Discover the endpoints + +Every subsequent step should read endpoint URLs from the discovery document rather than +hard-coding paths. Fetch it from the front topology: + +```bash +curl -sk https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/.well-known/openid-configuration | jq . +``` + +The response includes `issuer`, `authorization_endpoint`, `token_endpoint`, +`userinfo_endpoint`, `jwks_uri`, `registration_endpoint`, and the supported grant types, +scopes, response types, and PKCE methods. See the [Endpoint Reference](endpoints.md) for the +full document. + +## 5. Register a client + +```bash +curl -sk -X POST \ + https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/client/register \ + -H 'Content-Type: application/json' \ + -d '{ + "client_name": "my-first-client", + "redirect_uris": ["https://app.example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"] + }' | jq . +``` + +The response contains a generated `client_id` and, for confidential clients, a +`client_secret`. Store the secret securely — it is required to redeem authorization codes on +the token endpoint (see [Security](security.md#token-endpoint-client-authentication)). + +## 6. Run a Client Credentials flow + +The Client Credentials grant issues a token to a confidential client with no interactive user +login. Post the client credentials to the token endpoint advertised by discovery: + +```bash +curl -sk -X POST \ + https://knox:8443/gateway/knoxidf-ldap/knoxidf/api/v1/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=' \ + -d 'client_secret=' \ + -d 'scope=openid' | jq . +``` + +You will receive a Knox-signed access token (and, when `openid` is requested, an ID token). +Verify it against the JWKS endpoint (`jwks_uri`). + +## Next steps + +- To drive an interactive login, use the **Authorization Code + PKCE** flow — see the + [Endpoint Reference](endpoints.md#authorization-endpoint) and [Security](security.md#pkce). +- To broker login to an external OIDC Provider (Keycloak, Okta, Azure AD, Auth0), see + **[Federation](federation.md)**. +- To tune tokens, persistence, and claims, see the **[Configuration Reference](configuration.md)**. diff --git a/knox-site/docs/knoxidf/index.md b/knox-site/docs/knoxidf/index.md new file mode 100644 index 0000000000..4489b9b959 --- /dev/null +++ b/knox-site/docs/knoxidf/index.md @@ -0,0 +1,93 @@ + + +# Identity Federation (OIDC Provider) + +## Overview + +Historically, Apache Knox Gateway has acted as a *federation client* — it delegates +authentication to external Identity Providers (IdPs) such as CAS, SAML, OAuth 2.0, and +OpenID Connect (OIDC) providers (via pac4j). In that model Knox is strictly a relying +party and never an identity provider itself. + +**KnoxIDF** turns Apache Knox into an **OAuth 2.0 / OpenID Connect Provider (OP)** in its +own right, while retaining Knox's existing federation capabilities. With KnoxIDF: + +- Knox can **issue** OAuth 2.0 / OIDC tokens (access tokens and ID tokens) directly to clients. +- Knox can optionally **federate** identities and tokens from external, well-known OIDC + Providers (e.g. Keycloak, Okta, Azure AD, Auth0), brokering the login and then re-issuing + its own Knox-signed tokens. +- Downstream services integrate with Knox exactly as they would with any standard OIDC + provider — they only need to trust Knox, regardless of how authentication was performed + upstream. + +This makes Knox both an **OIDC Provider** and an **OIDC federation bridge**, enabling gradual +migration to — or a hybrid of — Knox-centric and external identity architectures. + +![KnoxIDF architecture: a client obtains OIDC tokens from Knox; Knox can optionally federate authentication to external OIDC providers and issues its own tokens to downstream services.](../assets/images/knoxidf/architecture.png) + +## Why KnoxIDF + +Many modern architectures expect a centralized OIDC Provider that issues tokens to +downstream services. Products like Okta, Azure AD, and Keycloak are commonly used for this, +but introducing and operating a separate IdP is not always desirable — especially in +Hadoop-centric or Knox-centric deployments where Knox is already the trusted edge. KnoxIDF +closes that gap: the gateway you already run at the perimeter becomes the token authority for +the services behind it. + +## Capabilities + +KnoxIDF is implemented as a new Knox service (role `KNOXIDF`) that can be attached to any +topology. It provides: + +| Capability | Description | +|------------|-------------| +| Standard OIDC endpoints | Discovery (`.well-known/openid-configuration`), authorization, token, userinfo, JWKS, and dynamic client registration. | +| Client Credentials flow | Machine-to-machine token issuance. | +| Authorization Code flow + PKCE | Interactive user login with PKCE (S256) for public clients and `client_secret` for confidential clients. | +| Refresh tokens | Refresh-token grant with rotation. | +| Consent | A one-time-per-(user, client) consent screen for the Authorization Code flow. | +| Federation (optional) | Broker login to one or more external OIDC Providers and re-issue Knox tokens. | +| Attribute enrichment | Hard-coded ID-token claims and pluggable user-parameter providers (e.g. LDAP attributes). | +| Persistence | Federated identity data persisted for traceability and attribute reuse (ID-token data only — no access/refresh tokens or secrets). | + +## How it fits into Knox + +KnoxIDF operates independently from pac4j-based inbound authentication and does not change +existing gateway authentication flows. A topology that includes the `KNOXIDF` service exposes +the OIDC endpoints; the topology's own authentication/federation providers (Shiro/LDAP, +SSOCookie, JWT, etc.) still govern how the caller is authenticated before KnoxIDF issues a +token. This keeps KnoxIDF modular and composable with the rest of Knox. + +## Where to go next + +- **[Getting Started](getting_started.md)** — build, deploy, register a client, and run your first flow. +- **[Endpoint Reference](endpoints.md)** — every REST endpoint KnoxIDF exposes. +- **[Configuration Reference](configuration.md)** — every configuration parameter. +- **[Security](security.md)** — client authentication, PKCE, consent, redirect-URI validation, and secret handling. +- **[Federation](federation.md)** — brokering login to external OIDC Providers. +- **[Operations](operations.md)** — high availability, rate limiting, signing-key rotation, and auditing. +- **[Integrations](integrations/polaris.md)** — worked examples of downstream services trusting KnoxIDF (e.g. replacing Keycloak in Apache Polaris). + +!!! note "Relationship to KIP-18" + KnoxIDF was originally proposed and prototyped in + [KIP-18 — Knox as OIDC Provider](https://cwiki.apache.org/confluence/spaces/KNOX/pages/406618787/KIP-18+-+Knox+as+OIDC+Provider). + KIP-18 describes the original design and proof-of-concept. The implementation has since + evolved (for example, refresh-token support, hardened client authentication, and + automatic federated-identity persistence were added after the initial proposal). Where the + KIP and this documentation differ, **this documentation reflects the current code and is + authoritative**; KIP-18 remains useful background on the motivation and design. diff --git a/knox-site/docs/knoxidf/integrations/polaris.md b/knox-site/docs/knoxidf/integrations/polaris.md new file mode 100644 index 0000000000..439cd79a5e --- /dev/null +++ b/knox-site/docs/knoxidf/integrations/polaris.md @@ -0,0 +1,533 @@ + + +# Apache Polaris (Client Credentials) + +[Apache Polaris](https://polaris.apache.org/) is a catalog for Apache Iceberg. Its +getting-started stack ships with a [Keycloak](https://www.keycloak.org/) integration that shows +Polaris trusting an **external** OpenID Connect Provider for machine-to-machine access. Because +KnoxIDF is a standard OIDC Provider, it can take Keycloak's place: Polaris trusts Knox exactly as +it would trust Keycloak, and clients obtain access tokens from Knox using the **Client +Credentials** grant. + +This page walks through swapping Keycloak for KnoxIDF in Polaris' getting-started environment and +verifying end to end that Knox-issued tokens are accepted by the realms Polaris configures to +trust an external IdP. + +!!! info "What this validates" + That a downstream service configured for a Keycloak-style OIDC provider works, unchanged in + concept, against KnoxIDF — the client credentials flow, JWKS-based signature verification, and + claim-to-role/principal mapping. + +## How it fits together + +Polaris' getting-started stack defines three realms, each with a different authentication mode: + +| Realm | `polaris.authentication` type | Who issues the accepted token | +|-------|-------------------------------|-------------------------------| +| `realm-internal` | `internal` | Polaris' own token endpoint (`root:s3cr3t`). A Knox token is **rejected**. | +| `realm-external` | `external` | The external OIDC Provider only — here, **KnoxIDF**. | +| `realm-mixed` | `mixed` | Either Polaris **or** the external OIDC Provider (**KnoxIDF**). | + +Polaris is pointed at KnoxIDF via Quarkus OIDC. It fetches KnoxIDF's discovery document and JWKS, +validates the token signature and `iss`, and maps claims to a Polaris principal and roles: + +```mermaid +sequenceDiagram + participant C as Client + participant K as KnoxIDF (knoxidf-token) + participant P as Polaris (realm-external / realm-mixed) + C->>K: POST /token (grant_type=client_credentials, client_id, client_secret) + K-->>C: Knox-signed access token (JWT) + C->>P: GET /api/management/v1/catalogs (Authorization: Bearer , Polaris-Realm: realm-external) + P->>K: GET /.well-known/openid-configuration, /jwks (once, cached) + P->>P: verify signature + iss, map principal_id / principal_name / principal_roles + P-->>C: 200 OK +``` + +## Prerequisites + +- **Docker** — to run the Polaris getting-started stack. +- **A running Knox with KnoxIDF**, reachable from the Polaris container at + `https://host.docker.internal:8443`. If you have not built and started Knox yet, follow + [Getting Started](../getting_started.md) first. +- **Polaris source** — `git clone https://github.com/apache/polaris.git` (this guide assumes it is + cloned at `~/projects/polaris`). + +!!! note "host.docker.internal" + The Polaris container reaches the Knox process running on your host through + `host.docker.internal`. On Linux, add + `--add-host=host.docker.internal:host-gateway` (or the Compose `extra_hosts` equivalent) if + your Docker version does not resolve it automatically. + +## 1. Deploy the `knoxidf-token` topology + +For this integration you only need a **single** topology — `knoxidf-token` — fronted by Knox's +`JWTProvider`. Save the following as `$KNOX_HOME/conf/topologies/knoxidf-token.xml`: + +```xml + + + + + federation + JWTProvider + true + + knox.token.exp.server-managed + true + + + jwt.expected.issuer + https://host.docker.internal:8443/gateway/knoxidf-sso/knoxidf, https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf + + + jwt.unauthenticated.path.list + /knoxidf/api/v1/.well-known/openid-configuration,/knoxidf/api/v1/jwks + + + + + + KNOXIDF + + knoxidf.knox.token.ttl + 120000 + + + knoxidf.knox.token.issuer + https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf + + + knoxidf.knox.token.limit.per.user + -1 + + + knoxidf.knox.token.hardcoded.claim.mappings + principal_roles=admin;scope=openid;principal_id=0;principal_name=root + + + +``` + +A few parameters are load-bearing for Polaris: + +| Parameter | Why Polaris needs it | +|-----------|----------------------| +| `jwt.unauthenticated.path.list` | Lets Polaris reach `/.well-known/openid-configuration` and `/jwks` **without** a bearer token, so it can bootstrap discovery and signature verification. | +| `jwt.expected.issuer` | Must contain the same issuer string Knox stamps into the token (see below), so the `JWTProvider` accepts KnoxIDF's own tokens on this topology. | +| `knoxidf.knox.token.issuer` | Sets the `iss` claim to the topology's own URL. Polaris' `quarkus.oidc.auth-server-url` resolves discovery from this issuer, so the two must agree. | +| `knoxidf.knox.token.hardcoded.claim.mappings` | **Required.** Polaris resolves a principal and its roles from token claims. Without these claims Polaris rejects the token even though the signature is valid. | + +!!! warning "The hard-coded claim mappings are mandatory for Polaris" + `principal_roles=admin;scope=openid;principal_id=0;principal_name=root` injects exactly the + claims Polaris' OIDC mapping reads: + + | Claim | Polaris config that consumes it | + |-------|---------------------------------| + | `principal_roles` | `quarkus.oidc.roles.role-claim-path=principal_roles` | + | `principal_id` | `polaris.oidc.principal-mapper.id-claim-path=principal_id` | + | `principal_name` | `polaris.oidc.principal-mapper.name-claim-path=principal_name` | + | `scope` | Standard OAuth scope claim (`openid`). | + + `principal_id=0` / `principal_name=root` map the client onto Polaris' bootstrap `root` + principal. Adjust these to match a real Polaris principal for anything beyond a smoke test. See + the [Configuration Reference](../configuration.md#hard-coded-claim-mappings) for the underlying + `knox.token.hardcoded.claim.mappings` parameter. + +Knox hot-deploys the topology within a few seconds. Confirm discovery is reachable: + +```bash +curl -sk https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/.well-known/openid-configuration | jq . +``` + +## 2. Register a client for the Client Credentials flow + +Register a confidential client and keep its `client_id` / `client_secret` — Polaris and its setup +scripts authenticate with them. (See [Getting Started §5](../getting_started.md#5-register-a-client) +for details; if your registration endpoint is not open anonymously, register through whichever +front topology authenticates you.) + +```bash +curl -sk -X POST \ + https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/client/register \ + -H 'Content-Type: application/json' \ + -d '{ + "client_name": "polaris", + "grant_types": ["client_credentials"] + }' | jq . +``` + +Confirm the credentials mint a token before wiring up Polaris: + +```bash +curl -sk -X POST \ + https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=' \ + -d 'client_secret=' | jq -r .access_token +``` + +Decode the resulting JWT (e.g. at [jwt.io](https://jwt.io) or with `jq`) and verify it carries +`iss`, `principal_roles`, `principal_id`, and `principal_name`. + +## 3. Create the Polaris environment + +Polaris' getting-started tree keeps one directory per IdP under `getting-started/`. Create a +KnoxIDF variant alongside the Keycloak one: + +```bash +cd ~/projects/polaris/getting-started +cp -r keycloak polaris_knoxidf # start from the Keycloak template +``` + +Then edit `polaris_knoxidf/docker-compose.yml` to point Polaris at KnoxIDF and **remove the +Keycloak service** — Knox now plays that role. The result looks like this: + +```yaml +services: + + polaris: + image: apache/polaris:latest + ports: + - "8181:8181" # API + - "8182:8182" # management (metrics + health) + - "5005:5005" # optional debugger + environment: + POLARIS_BOOTSTRAP_CREDENTIALS: realm-internal,root,s3cr3t;realm-external,root,s3cr3t;realm-mixed,root,s3cr3t + polaris.realm-context.realms: realm-internal,realm-external,realm-mixed + polaris.authentication.type: internal + polaris.authentication."realm-external".type: external + polaris.authentication."realm-mixed".type: mixed + quarkus.oidc.tenant-enabled: true + + # --- Trust KnoxIDF as the external OIDC Provider --- + quarkus.oidc.auth-server-url: https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf/api/v1 + quarkus.oidc.client-id: + quarkus.oidc.roles.role-claim-path: principal_roles + polaris.oidc.principal-mapper.id-claim-path: principal_id + polaris.oidc.principal-mapper.name-claim-path: principal_name + + # --- Accept Knox's self-signed dev certificate (dev only) --- + quarkus.tls.trust-all: "true" + quarkus.oidc.tls.tls-configuration-name: "" + quarkus.oidc.tls.verification: none + + polaris.features."ALLOW_INSECURE_STORAGE_TYPES": "true" + polaris.features."SUPPORTED_CATALOG_STORAGE_TYPES": "[\"FILE\",\"S3\",\"GCS\",\"AZURE\"]" + polaris.readiness.ignore-severe-issues: "true" + healthcheck: + test: ["CMD", "curl", "http://localhost:8182/q/health"] + interval: 2s + timeout: 10s + retries: 10 + start_period: 10s + + polaris-setup: + image: alpine/curl + depends_on: + polaris: + condition: service_healthy + environment: + - CLIENT_ID=root + - CLIENT_SECRET=s3cr3t + volumes: + - ../assets/polaris/:/polaris + entrypoint: "/bin/sh" + command: + - "-c" + - >- + apk add --no-cache jq && + chmod +x /polaris/create-catalog.sh && + token=$$(curl -sk -X POST -H "Content-Type: application/x-www-form-urlencoded" 'https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf/api/v1/token' -d 'client_id=' -d 'client_secret=' -d 'grant_type=client_credentials' | jq -r .access_token) && + /polaris/create-catalog.sh realm-internal && + /polaris/create-catalog.sh realm-external $$token && + /polaris/create-catalog.sh realm-mixed $$token +``` + +The key changes relative to the Keycloak template: + +- **`quarkus.oidc.auth-server-url`** points at the `knoxidf-token` topology's OIDC base + (`…/knoxidf/api/v1`) instead of Keycloak. This is the issuer Polaris uses for discovery, so it + must match `knoxidf.knox.token.issuer` from the topology. +- **`quarkus.oidc.client-id`** is your registered KnoxIDF `client_id`. +- **`quarkus.oidc.roles.role-claim-path`** / **`principal-mapper.*-claim-path`** read the + `principal_*` claims injected by the topology's hard-coded claim mappings. +- **`quarkus.tls.trust-all` / `quarkus.oidc.tls.verification: none`** let Quarkus accept Knox's + self-signed development certificate. **Development only** — provide a real trust store in + production. +- The **`polaris-setup`** helper fetches a KnoxIDF token via client credentials and uses it to + create a catalog in the `realm-external` and `realm-mixed` realms (which trust Knox), while + `realm-internal` is seeded with Polaris' own `root:s3cr3t` credentials. + +!!! danger "Never commit real secrets" + Replace `` / `` with your registered values. `client_secret` is a + credential — keep it out of version control. + +## 4. Run it + +```bash +cd ~/projects/polaris +docker compose -f getting-started/polaris_knoxidf/docker-compose.yml up +``` + +Polaris comes up on `http://localhost:8181` (management on `8182`). The `polaris-setup` container +runs once, obtains a KnoxIDF token, and creates the `quickstart_catalog` in each realm. + +## 5. Verify the flow + +The verification calls the Polaris management API (`/api/management/v1/catalogs`) with different +tokens and `Polaris-Realm` headers, and asserts that each combination returns the HTTP status the +realm's authentication mode dictates. The script below automates the whole matrix: it mints a +KnoxIDF token via client credentials, mints Polaris-native tokens for the `internal` and `mixed` +realms (using `root:s3cr3t` against Polaris' own `/api/catalog/v1/oauth/tokens`), then exercises +every `(token, realm)` pair. + +Save it as `polaris_knoxidf_test.sh` and fill in your registered `client_id` / `client_secret`: + +??? example "polaris_knoxidf_test.sh" + ```bash + #!/usr/bin/env bash + set -euo pipefail + + ############################################################################### + # CONFIG + ############################################################################### + + POLARIS_URL="http://localhost:8181" + KNOX_TOKEN_URL="https://localhost:8443/gateway/knoxidf-token/knoxidf/api/v1/token" + + CLIENT_ID="" + CLIENT_SECRET="" + + ############################################################################### + # 1. OBTAIN KNOXIDF TOKEN (client credentials) + ############################################################################### + + echo "" + echo "==================================================================" + echo " OBTAINING TOKEN FROM KNOXIDF" + echo "==================================================================" + + KNOX_TOKEN=$(curl -sk \ + -X POST "$KNOX_TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "grant_type=client_credentials" \ + | jq -r '.access_token') + + echo "KnoxIDF token: $KNOX_TOKEN" + echo "" + + ############################################################################### + # 2. OBTAIN POLARIS-NATIVE TOKENS (internal + mixed realms) + ############################################################################### + + echo "" + echo "==================================================================" + echo " OBTAINING POLARIS TOKENS (Internal + Mixed)" + echo "==================================================================" + + POLARIS_TOKEN_REALM_INTERNAL=$(curl -s "$POLARIS_URL/api/catalog/v1/oauth/tokens" \ + --user root:s3cr3t \ + -H 'Polaris-Realm: realm-internal' \ + -d 'grant_type=client_credentials' \ + -d 'scope=PRINCIPAL_ROLE:ALL' | jq -r .access_token) + + POLARIS_TOKEN_REALM_MIXED=$(curl -s "$POLARIS_URL/api/catalog/v1/oauth/tokens" \ + --user root:s3cr3t \ + -H 'Polaris-Realm: realm-mixed' \ + -d 'grant_type=client_credentials' \ + -d 'scope=PRINCIPAL_ROLE:ALL' | jq -r .access_token) + + echo "Polaris token (realm-internal): $POLARIS_TOKEN_REALM_INTERNAL" + echo "" + echo "Polaris token (realm-mixed) : $POLARIS_TOKEN_REALM_MIXED" + echo "" + + ############################################################################### + # 3. TEST CASES + ############################################################################### + + function test_curl() { + local token="$1" + local realm="$2" + local description="$3" + local expected="$4" # Expected outcome: "SUCCEED" or "FAIL" + + echo "" + echo "==================================================================" + echo " $description" + echo "==================================================================" + + local response status body + response=$(curl -sk -w "%{http_code}" \ + -H "Authorization: Bearer $token" \ + -H "Polaris-Realm: $realm" \ + -H "Accept: application/json" \ + "$POLARIS_URL/api/management/v1/catalogs") + + status="${response: -3}" # last 3 characters = HTTP code + body="${response:0:${#response}-3}" + + local expected_code + if [[ "$expected" == "SUCCEED" ]]; then + expected_code=200 + else + expected_code=401 + fi + + if [ "$status" -eq "$expected_code" ]; then + echo "✅ PASS: Got HTTP $status as expected" + if [ "$status" -eq 200 ]; then + echo "Response JSON:" + echo "$body" | jq . + fi + else + echo "❌ FAIL: Got HTTP $status, expected $expected_code" + fi + } + + # External Knox token + test_curl "$KNOX_TOKEN" "realm-internal" "TEST: Knox token → realm-internal (SHOULD FAIL)" FAIL + test_curl "$KNOX_TOKEN" "realm-external" "TEST: Knox token → realm-external (SHOULD SUCCEED)" SUCCEED + test_curl "$KNOX_TOKEN" "realm-mixed" "TEST: Knox token → realm-mixed (SHOULD SUCCEED)" SUCCEED + + # Polaris-native tokens + test_curl "$POLARIS_TOKEN_REALM_INTERNAL" "realm-internal" "TEST: Polaris token (internal) → realm-internal (SHOULD SUCCEED)" SUCCEED + test_curl "$POLARIS_TOKEN_REALM_MIXED" "realm-mixed" "TEST: Polaris token (mixed) → realm-mixed (SHOULD SUCCEED)" SUCCEED + + # Cross-realm failure + test_curl "$POLARIS_TOKEN_REALM_INTERNAL" "realm-mixed" "TEST: Polaris token (internal) → realm-mixed (SHOULD FAIL)" FAIL + + echo "" + echo "==================================================================" + echo "ALL TESTS COMPLETE" + echo "==================================================================" + ``` + +Run it once the stack is up: + +```bash +chmod +x polaris_knoxidf_test.sh +./polaris_knoxidf_test.sh +``` + +### Expected results + +The script asserts these six `(token, realm)` combinations: + +| # | Token source | `Polaris-Realm` | Expected | +|---|--------------|-----------------|----------| +| 1 | KnoxIDF (client credentials) | `realm-internal` | **401** — internal realm rejects external tokens | +| 2 | KnoxIDF (client credentials) | `realm-external` | **200** — external realm trusts KnoxIDF | +| 3 | KnoxIDF (client credentials) | `realm-mixed` | **200** — mixed realm accepts KnoxIDF | +| 4 | Polaris-native (internal) | `realm-internal` | **200** | +| 5 | Polaris-native (mixed) | `realm-mixed` | **200** | +| 6 | Polaris-native (internal) | `realm-mixed` | **401** — token minted for another realm | + +A sample run (token values and JSON bodies trimmed): + +??? success "Sample output" + ```text + ================================================================== + OBTAINING TOKEN FROM KNOXIDF + ================================================================== + KnoxIDF token: eyJqa3UiOiJodHRwczovL2xvY2FsaG9zdDo4NDQz... + + ================================================================== + OBTAINING POLARIS TOKENS (Internal + Mixed) + ================================================================== + Polaris token (realm-internal): eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... + Polaris token (realm-mixed) : eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... + + ================================================================== + TEST: Knox token → realm-internal (SHOULD FAIL) + ================================================================== + ✅ PASS: Got HTTP 401 as expected + + ================================================================== + TEST: Knox token → realm-external (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + Response JSON: + { + "catalogs": [ + { + "type": "INTERNAL", + "name": "quickstart_catalog", + "properties": { "default-base-location": "file:///var/tmp/quickstart_catalog/" }, + "storageConfigInfo": { + "storageType": "FILE", + "allowedLocations": [ "file:///var/tmp/quickstart_catalog/" ] + } + } + ] + } + + ================================================================== + TEST: Knox token → realm-mixed (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + + ================================================================== + TEST: Polaris token (internal) → realm-internal (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + + ================================================================== + TEST: Polaris token (mixed) → realm-mixed (SHOULD SUCCEED) + ================================================================== + ✅ PASS: Got HTTP 200 as expected + + ================================================================== + TEST: Polaris token (internal) → realm-mixed (SHOULD FAIL) + ================================================================== + ✅ PASS: Got HTTP 401 as expected + + ================================================================== + ALL TESTS COMPLETE + ================================================================== + ``` + +Cases 2 and 3 are the ones that matter: a `200` from `realm-external` (and `realm-mixed`) with a +**KnoxIDF-issued** token confirms KnoxIDF has fully replaced Keycloak for the client credentials +flow — Polaris fetched discovery and JWKS from Knox, verified the signature and issuer, and mapped +the `principal_*` claims onto a Polaris principal and role. Case 1 confirms the `internal` realm +still refuses external tokens, and case 6 confirms realm isolation. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| `401` from `realm-external` with a valid-looking token | `iss` in the token does not match `quarkus.oidc.auth-server-url`. Align `knoxidf.knox.token.issuer` with the URL Polaris uses. | +| Polaris logs "unable to resolve principal" / role errors | `principal_id` / `principal_name` / `principal_roles` claims missing. Check `knoxidf.knox.token.hardcoded.claim.mappings` on the topology. | +| Polaris cannot fetch discovery/JWKS (connection or `401` at startup) | Discovery/JWKS not anonymous. Ensure `jwt.unauthenticated.path.list` lists both `/.well-known/openid-configuration` and `/jwks`, and that `host.docker.internal:8443` is reachable from the container. | +| TLS handshake failures | Knox's dev certificate is not trusted. For local testing set `quarkus.tls.trust-all: "true"`; in production configure a proper trust store. | +| Token expires mid-test | `knoxidf.knox.token.ttl` is `120000` (2 min) in the sample topology. Raise it or re-request the token. | + +## Next steps + +- Drive an interactive browser login (Authorization Code + PKCE) instead of client credentials — + see the [Endpoint Reference](../endpoints.md#authorization-endpoint) and + [Security](../security.md#pkce). +- Broker Polaris logins to an upstream OIDC Provider while still issuing Knox tokens — see + [Federation](../federation.md). +- Tune token lifetime, issuer, and claims — see the + [Configuration Reference](../configuration.md). diff --git a/knox-site/docs/knoxidf/integrations/polaris_console.md b/knox-site/docs/knoxidf/integrations/polaris_console.md new file mode 100644 index 0000000000..7c83c2e69e --- /dev/null +++ b/knox-site/docs/knoxidf/integrations/polaris_console.md @@ -0,0 +1,471 @@ + + +# Apache Polaris Console (Authorization Code + PKCE) + +The [Apache Polaris](https://polaris.apache.org/) **Console** is a browser single-page +application (SPA) — a React/Vite app that talks to the Polaris REST API. Unlike the +[Client Credentials integration](polaris.md), which is machine-to-machine, the Console signs in a +**human**: it runs the OAuth2 **Authorization Code + PKCE** flow against KnoxIDF, obtains a +Knox-issued access token in the browser, and then calls the Polaris API with that token. + +This page wires KnoxIDF up as the Console's OpenID Connect Provider end to end. It covers the two +KnoxIDF topologies involved, the KnoxSSO topology that authenticates the user, registering the +Console as a **public** (secret-less) PKCE client, the Polaris backend configuration, the +Console's `.env`, and — because this flow crosses three origins in the browser — the CORS wiring +that makes it all work. + +!!! info "What this validates" + That a browser SPA can authenticate an interactive user through KnoxIDF using Authorization + Code + PKCE with **no client secret**, that the resulting Knox token is accepted by the + Polaris API, and that the three cross-origin surfaces (discovery, token, and the Polaris API) + are reachable from the SPA. + +!!! note "Public client, no secret" + A browser SPA cannot keep a secret. This flow therefore uses a **public** client + (`token_endpoint_auth_method` = `none`) protected by **PKCE** (`S256`). There is no + `client_secret` anywhere in the browser, the `.env`, or the token request — the proof of + possession is the PKCE `code_verifier`. + +## How it fits together + +Three server-side pieces cooperate, plus the Polaris backend: + +| Component | Topology | Role in this flow | +|-----------|----------|-------------------| +| **KnoxSSO** | `knoxsso` | Authenticates the human (LDAP/Shiro) and mints the `hadoop-jwt` SSO cookie. | +| **KnoxIDF front** | `knoxidf-sso` | Serves discovery, `/authorize`, and the callback. Browser-facing, protected by the SSO cookie. Issues the authorization **code**. | +| **KnoxIDF token** | `knoxidf-token` | Serves `/token`. Exchanges the code (+ PKCE verifier) for the access token. This is the `iss` Polaris trusts. | +| **Polaris** | — (Quarkus) | Trusts KnoxIDF via Quarkus OIDC; serves `/api/management` and `/api/catalog` to the Console. | + +The Console fetches discovery from `knoxidf-sso`; the discovery document advertises the +`authorization_endpoint` on `knoxidf-sso` and the `token_endpoint` on `knoxidf-token` (the split +comes from `knoxidf.token.exchange.topology.name`). The user logs in once via KnoxSSO; the +`hadoop-jwt` cookie then lets `/authorize` issue a code without a second login. + +```mermaid +sequenceDiagram + autonumber + participant B as Browser (Console SPA, :5173) + participant SSO as knoxidf-sso (/authorize, discovery) + participant KS as knoxsso (LDAP login) + participant TOK as knoxidf-token (/token) + participant P as Polaris API (:8181) + + B->>SSO: GET /.well-known/openid-configuration (XHR) + Note over B,SSO: CORS surface #1 + SSO-->>B: authorization_endpoint (sso), token_endpoint (token) + B->>SSO: top-level redirect to /authorize?...&code_challenge=...&code_challenge_method=S256 + SSO->>KS: no hadoop-jwt cookie → redirect to KnoxSSO login + KS-->>B: login form + B->>KS: username / password + KS-->>B: set hadoop-jwt cookie, redirect back to /authorize + B->>SSO: GET /authorize (now authenticated) + SSO-->>B: 302 to redirect_uri?code=... (http://localhost:5173/auth/callback) + B->>TOK: POST /token (code, code_verifier, client_id, redirect_uri) (XHR) + Note over B,TOK: CORS surface #2 — no client_secret + TOK-->>B: access_token (Knox-signed JWT) + B->>P: GET /api/management/v1/catalogs (Bearer token, Polaris-Realm) (XHR) + Note over B,P: CORS surface #3 + P->>TOK: fetch discovery + JWKS (server-side, cached) + P-->>B: 200 OK +``` + +!!! danger "Three CORS surfaces" + Because the SPA and the servers are on different origins, **three** cross-origin surfaces must + each return CORS headers. Miss any one and the browser blocks the request with a CORS error — + even though a `curl` from the shell succeeds. + + | # | Request | Server that must send CORS | + |---|---------|----------------------------| + | 1 | `GET /.well-known/openid-configuration` (discovery) | `knoxidf-sso` topology | + | 2 | `POST /token` | `knoxidf-token` topology | + | 3 | `GET/POST /api/**` (catalogs, principals, …) | Polaris (Quarkus) | + + `/authorize` is a **top-level browser navigation**, not an XHR, so it needs **no** CORS. + +## Prerequisites + +- A working KnoxIDF deployment (see [Getting Started](../getting_started.md)). +- A KnoxSSO topology able to authenticate a user (this page uses the demo LDAP on + `ldap://localhost:33389`). +- The Polaris getting-started stack from the [Client Credentials page](polaris.md) — this page + adds the Console and the browser flow on top of it. +- The [Polaris Console](https://github.com/apache/polaris-tools) checked out and its dev server + runnable (`npm run dev`, Vite on `http://localhost:5173`). + +!!! note "Hostnames in this guide" + Browser-facing Knox URLs use `https://localhost:8443`. The Polaris **container** reaches Knox + at `https://host.docker.internal:8443` (the two differ only because one caller is your host + browser and the other is a container). Whatever host you pick for the browser side, use the + **exact same host and scheme** everywhere it appears (see the redirect-loop warning below). + +## 1. KnoxSSO topology (authenticates the user) + +The Console flow reuses your existing `knoxsso` topology to log the user in and mint the +`hadoop-jwt` cookie. Only one setting matters for this integration — the **issuer** — and it must +match `knoxidf-sso` exactly. + +```xml + + KNOXSSO + + knoxsso.token.ttl + 86400000 + + + knoxsso.redirect.whitelist.regex + ^.*$ + + + knoxsso.cookie.samesite + Lax + + + + knoxsso.token.issuer + https://localhost:8443/gateway/knoxidf-sso/knoxidf + + +``` + +!!! danger "Issuer mismatch → infinite redirect loop" + If `knoxsso.token.issuer` and `knoxidf-sso`'s `jwt.expected.issuer` disagree — even only by + scheme (`http` vs `https`) — the SSO cookie `knoxidf-sso` receives is rejected, so it bounces + the browser back to KnoxSSO, which mints another cookie, and so on. Chrome shows + `ERR_TOO_MANY_REDIRECTS`. Make the two strings **byte-for-byte identical**, and clear any + stale `hadoop-jwt` cookie after changing them. + +## 2. `knoxidf-sso` topology (discovery, `/authorize`, callback) + +This browser-facing topology is protected by the `SSOCookieProvider` (so `/authorize` can rely on +the KnoxSSO login) and must serve discovery cross-origin to the SPA. Note the **CORS provider is +listed first**. + +```xml + + + + + webappsec + WebAppSec + true + cors.enabledtrue + cors.allowOriginhttp://localhost:5173 + cors.supportedMethodsGET,POST,HEAD,OPTIONS + cors.supportedHeaders* + cors.exposedHeaders* + cors.supportsCredentialsfalse + + + + federation + SSOCookieProvider + true + + sso.authentication.provider.url + https://localhost:8443/gateway/knoxsso/api/v1/websso + + + + jwt.expected.issuer + https://localhost:8443/gateway/knoxidf-sso/knoxidf + + + + sso.unauthenticated.path.list + /knoxidf/api/v1/.well-known/openid-configuration,/knoxidf/api/v1/jwks,/knoxidf/api/v1/client/register,/knoxidf/api/v1/callback,/knoxidf/api/v1/websso/federated/op + + + + + + KNOXIDF + + + knoxidf.token.exchange.topology.name + knoxidf-token + + + knoxidf.knox.token.issuer + https://localhost:8443/gateway/knoxidf-sso/knoxidf + + + + knoxidf.client.registration.anonymous.allowed + true + + + +``` + +!!! note "Federated upstream OPs are optional" + `knoxidf-sso` can additionally federate to upstream OpenID Providers (Keycloak, Auth0, …) via + the `websso/federated/op` path. That is orthogonal to this Console flow — see + [Federation](../federation.md). Keep any upstream client secrets **out** of files you commit. + +## 3. `knoxidf-token` topology (the `/token` endpoint) + +This is the topology from the [Client Credentials page](polaris.md), with **one addition**: a CORS +provider so the SPA's `POST /token` (XHR) succeeds. The hardcoded claim mappings shape the token +into the principal/roles Polaris expects. + +```xml + + + + + webappsec + WebAppSec + true + cors.enabledtrue + cors.allowOriginhttp://localhost:5173 + cors.supportedMethodsGET,POST,HEAD,OPTIONS + cors.supportedHeaders* + cors.exposedHeaders* + cors.supportsCredentialsfalse + + + + federation + JWTProvider + true + knox.token.exp.server-managedtrue + + jwt.expected.issuer + https://host.docker.internal:8443/gateway/knoxidf-sso/knoxidf, https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf + + + jwt.unauthenticated.path.list + /knoxidf/api/v1/.well-known/openid-configuration,/knoxidf/api/v1/jwks + + + + + + KNOXIDF + knoxidf.knox.token.ttl120000 + + knoxidf.knox.token.issuer + https://host.docker.internal:8443/gateway/knoxidf-token/knoxidf + + knoxidf.knox.token.limit.per.user-1 + + + knoxidf.knox.token.hardcoded.claim.mappings + principal_roles=admin;scope=openid;principal_id=0;principal_name=root + + + +``` + +!!! warning "The token issuer is what Polaris trusts" + `knoxidf-token`'s `knoxidf.knox.token.issuer` is the `iss` baked into the access token. It + must equal Polaris' `quarkus.oidc.auth-server-url` base (`host.docker.internal:8443`), which + is why the browser side (`localhost`) and the Polaris side (`host.docker.internal`) differ. + +## 4. Register the Console as a public PKCE client + +Register a **public** client whose only redirect URI is the Console's callback. Because the +redirect URI is a **loopback** address, plain `http` is permitted (RFC 8252); a non-loopback +redirect URI would have to use `https`. + +```bash +curl -sk -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + 'https://localhost:8443/gateway/knoxidf-sso/knoxidf/api/v1/client/register' \ + -d 'redirect_uris=http://localhost:5173/auth/callback' \ + -d 'allowed_scopes=openid,profile,email,offline_access' +``` + +The response's `token_id` is your `client_id`. A public client has **no usable secret** — the +Console never sends one; PKCE is the client's proof of possession. + +```json +{ + "token_id": "", + "redirect_uris": "http://localhost:5173/auth/callback", + "allowed_scopes": "openid,profile,email,offline_access" +} +``` + +!!! note "Registration uses form encoding" + `/client/register` is `application/x-www-form-urlencoded` (comma-separated `redirect_uris`), + **not** JSON. See the [Endpoint Reference](../endpoints.md#client-registration-endpoint). + +## 5. Configure Polaris (backend) + +Start from the `docker-compose.yml` in the [Client Credentials page](polaris.md) — the OIDC +settings (`quarkus.oidc.auth-server-url`, `quarkus.oidc.client-id`, the `principal-mapper` and +`role-claim-path`) are unchanged. Add the **CORS** block so the Console (a different origin) can +call `/api/**`: + +```yaml + environment: + # ... existing OIDC / realm settings from the Client Credentials guide ... + + # CORS surface #3: allow the Console SPA (Vite dev server) to call /api/**. + # Without these, the browser blocks cross-origin /api/** requests (the preflight + # of the Authorization and Polaris-Realm headers fails). + quarkus.http.cors.enabled: "true" + quarkus.http.cors.origins: "http://localhost:5173" + quarkus.http.cors.methods: "GET,POST,PUT,DELETE,PATCH,OPTIONS,HEAD" + quarkus.http.cors.headers: "Authorization,Content-Type,Accept,Origin,X-Requested-With,Polaris-Realm" + quarkus.http.cors.exposed-headers: "*" + quarkus.http.cors.access-control-max-age: "24H" + quarkus.http.cors.access-control-allow-credentials: "false" +``` + +!!! danger "It is `quarkus.http.cors.enabled`, not `quarkus.http.cors`" + On Quarkus 3.x the enable flag is **`quarkus.http.cors.enabled`**. Setting a bare + `quarkus.http.cors: "true"` logs `Unrecognized configuration key "quarkus.http.cors" ... it + will be ignored` and **no** `Access-Control-*` headers are emitted — the OPTIONS preflight + returns `200` but without CORS headers, and the browser still blocks the call. + +!!! warning "The `Polaris-Realm` header must be allowed" + The Console sends a custom `Polaris-Realm` header (e.g. `realm-external`). It must appear in + `quarkus.http.cors.headers`, or the preflight fails. + +!!! note "Recreate the container after env changes" + Environment changes only take effect on a fresh container. Re-create it, don't just restart: + `docker compose up -d --force-recreate polaris`. + +Verify the preflight actually carries CORS headers: + +```bash +curl -s -i -X OPTIONS 'http://localhost:8181/api/management/v1/catalogs' \ + -H 'Origin: http://localhost:5173' \ + -H 'Access-Control-Request-Method: GET' \ + -H 'Access-Control-Request-Headers: authorization,polaris-realm' +# Expect: access-control-allow-origin: http://localhost:5173 (and allow-methods/headers) +``` + +### Create a principal so the Console can show the signed-in user + +The Console shows the signed-in user's name in the top-right corner. It derives that name from +the token's **`sub`** claim (here `admin` — the KnoxSSO/LDAP user you log in as) and then looks it +up in Polaris' principal store via `GET /api/management/v1/principals/{sub}`. The name is shown +only if a **persisted principal with that exact name exists**; otherwise the Console falls back to +the generic label `User`. + +Polaris does **not** auto-create principals for federated logins, and the getting-started stack +bootstraps only `root` — so until a matching principal exists, the header shows `User`. Create one +whose name equals the `sub`. The `polaris-setup` container already obtains a service-admin token, +so add one more step reusing it: + +```yaml + # ... after the create-catalog.sh calls, still using the same root $token ... + curl -sk -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' -H 'Polaris-Realm: realm-external' \ + 'http://polaris:8181/api/management/v1/principals' \ + -d '{"principal":{"name":"admin"}}' +``` + +Then re-create the setup container: `docker compose up -d --force-recreate polaris-setup`. (Or run +the equivalent `POST /api/management/v1/principals` once by hand with any service-admin token.) + +!!! note "Display only — not the authorizing identity" + This principal exists purely so the Console can render a name. The API calls themselves are + still authorized by the token's `principal_*` claims (the hardcoded mapping on + `knoxidf-token`), independent of this principal. Because Polaris resolves the caller by name + only when `principal_id` is absent or `0`, and the mapping pins `principal_name=root`, the + caller remains `root` — the `admin` principal is looked up for display alone. + +## 6. Configure the Polaris Console (`.env`) + +Point the Console at the Polaris API and at KnoxIDF as its OIDC provider. Note the issuer URL uses +the **`/api/v1`** base (so discovery loads from `/api/v1/.well-known/openid-configuration`), the +redirect URI matches the one you registered, and there is **no client secret**. + +```bash +# Polaris API +VITE_POLARIS_API_URL=http://localhost:8181 +VITE_POLARIS_REALM=realm-external +VITE_POLARIS_PRINCIPAL_SCOPE=PRINCIPAL_ROLE:ALL + +# KnoxIDF as the OIDC provider (Authorization Code + PKCE, public client) +VITE_OIDC_ISSUER_URL=https://localhost:8443/gateway/knoxidf-sso/knoxidf/api/v1 +VITE_OIDC_CLIENT_ID= +VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback +VITE_OIDC_SCOPE=openid profile email +``` + +!!! note "Discovery `issuer` vs `VITE_OIDC_ISSUER_URL`" + `VITE_OIDC_ISSUER_URL` carries the `/api/v1` base so the SPA can find the discovery document. + The `issuer` **inside** that document is `https://localhost:8443/gateway/knoxidf-sso/knoxidf` + (no `/api/v1`). This is expected — the discovery base and the advertised issuer are allowed to + differ. + +## 7. Run it + +1. Deploy/redeploy the three topologies (`knoxsso`, `knoxidf-sso`, `knoxidf-token`). +2. `docker compose up -d --force-recreate polaris` (and the rest of the Polaris stack). +3. Start the Console dev server: `npm run dev` (serves `http://localhost:5173`). +4. Open `http://localhost:5173` and choose **Sign in with OIDC**. + +## 8. Walk through the login + +The Console's sign-in card offers a direct username/password path and, for the KnoxIDF +Authorization Code flow, **Sign in with OIDC**. The realm is set to `realm-external` and the scope +to `PRINCIPAL_ROLE:ALL`. + +![Polaris Console sign-in](../../assets/images/knoxidf/polaris_console_login.png) + +Clicking **Sign in with OIDC** redirects the browser to `knoxidf-sso`'s `/authorize`. With no SSO +cookie yet, KnoxSSO shows its login form; after a successful LDAP login the browser returns to +`/authorize`, which issues a code and redirects to `http://localhost:5173/auth/callback?code=…`. +The Console then exchanges the code (with its PKCE `code_verifier`) at `knoxidf-token`'s `/token`, +stores the access token in memory, and lands on the dashboard. + +![Polaris Console home after login](../../assets/images/knoxidf/polaris_console_home.png) + +## 9. Verify + +With the token in hand, the Console calls the Polaris API. Navigating to **Catalogs** should list +the catalog created by the getting-started setup: + +```bash +# The same call the Console makes (token minted via the browser flow): +curl -s 'http://localhost:8181/api/management/v1/catalogs' \ + -H "Authorization: Bearer " \ + -H "Polaris-Realm: realm-external" | jq . +``` + +A populated **Catalogs** page (and a `200` from the call above) confirms the full chain: +interactive login → Knox-issued token → Polaris accepting it. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `ERR_TOO_MANY_REDIRECTS` between KnoxSSO and `/authorize` | `knoxsso.token.issuer` ≠ `knoxidf-sso` `jwt.expected.issuer` (often `http` vs `https`) | Make the two issuer strings byte-for-byte identical; clear the stale `hadoop-jwt` cookie. | +| CORS error on `/.well-known/openid-configuration` | No CORS provider on `knoxidf-sso` | Add the WebAppSec CORS provider (surface #1), origin `http://localhost:5173`. | +| CORS error on `POST /token` | No CORS provider on `knoxidf-token` | Add the WebAppSec CORS provider (surface #2). | +| CORS error on `/api/management/**` or `/api/catalog/**` | Polaris (Quarkus) sends no CORS headers | Add the `quarkus.http.cors.*` block (surface #3) and re-create the container. | +| OPTIONS returns `200` but browser still blocks; log shows `Unrecognized configuration key "quarkus.http.cors"` | Wrong Quarkus key | Use `quarkus.http.cors.enabled`, not `quarkus.http.cors`. | +| Preflight fails only when the app sends `Polaris-Realm` | Header not in the allow-list | Add `Polaris-Realm` to `quarkus.http.cors.headers`. | +| `/authorize` cannot log in | KnoxSSO cannot reach its user store | Ensure the LDAP/identity store in `knoxsso` is running and reachable (`ldap://localhost:33389` in the demo). | +| `redirect_uri` rejected at `/authorize` | Callback not registered / mismatch | Register `http://localhost:5173/auth/callback` and set the identical value in `VITE_OIDC_REDIRECT_URI`. | +| Token accepted but `403` from Polaris | Claim → role/principal mapping | Check `knoxidf.knox.token.hardcoded.claim.mappings` vs Polaris' `role-claim-path` / `principal-mapper`. | +| Header shows `User` instead of the signed-in name | No persisted Polaris principal matches the token `sub` | Create a principal named after `sub` (e.g. `admin`) — see [Create a principal so the Console can show the signed-in user](#create-a-principal-so-the-console-can-show-the-signed-in-user). | + +## Next steps + +- [Client Credentials integration](polaris.md) — the machine-to-machine counterpart to this flow. +- [Endpoint Reference](../endpoints.md) — `/authorize`, `/token`, discovery, and PKCE details. +- [Federation](../federation.md) — front `knoxidf-sso` with upstream OpenID Providers. +- [Security](../security.md) — dynamic client registration and hardening. diff --git a/knox-site/docs/knoxidf/operations.md b/knox-site/docs/knoxidf/operations.md new file mode 100644 index 0000000000..ef559322d7 --- /dev/null +++ b/knox-site/docs/knoxidf/operations.md @@ -0,0 +1,183 @@ + + +# Operations + +This chapter covers running KnoxIDF in production: where identity state is persisted, how to +rotate signing keys without disrupting clients, how requests are audited, and how KnoxIDF behaves +behind a highly available Knox deployment. + +## Federated identity persistence + +KnoxIDF persists federated-identity data so that the same upstream user maps to a stable Knox +subject across logins and restarts, and so a filtered set of profile attributes can be reused. It +stores **only ID-token–derived data** — no access tokens, refresh tokens, or OP client secrets +(see [Security → What is (and isn't) stored at rest](security.md#what-is-and-isnt-stored-at-rest)). + +### Backend selection + +The persistence backend activates automatically whenever a topology with the `KNOXIDF` or +`KNOXIDF_ADMIN` role is deployed. Which backend is used follows the gateway's database +configuration: + +| `gateway.database.type` | Backend | Notes | +|-------------------------|---------|-------| +| `none` (default) or `derbydb` | Self-provisioning **embedded Derby** | Uses the same physical embedded database as token state (under the gateway security directory). Zero setup. | +| A real external type (`postgresql`, `mysql`, `oracle`, …) | **JDBC-backed** store | Uses the operator-configured external database. Recommended for HA. | + +You can also pin the implementation explicitly with the service property +`gateway.service.KnoxIDFFederatedIdentityService.impl` (Empty / Derby / JDBC); an explicit value +always wins over auto-selection. Setting it to the empty (no-op) implementation disables +persistence. + +!!! note "Use an external database for multi-instance deployments" + The embedded Derby store is local to a single gateway process. For a clustered / HA + deployment where more than one Knox instance must share federated-identity state, configure an + external database (see [Configuration → Persistence](configuration.md#persistence-and-database)). + +### What is stored + +- **Identity mapping:** Knox subject (UUIDv5), provider name (upper-cased), external subject, + external issuer. +- **Attributes:** the allow-listed profile claims (`preferred_username`, `email`, `email_verified`, + `given_name`, `family_name`, `name`, `locale`). + +The identity row and its attributes are written in a **single transaction**, and a unique +constraint on `(provider, external_issuer, external_subject)` makes concurrent first-logins of the +same user converge on one row. + +## Signing-key rotation + +KnoxIDF signs issued JWTs with the gateway signing key (`gateway.signing.key.alias`, default +`gateway-identity`) and publishes the corresponding public key(s) on the +[JWKS endpoint](endpoints.md#jwks-endpoint). Each published key is identified by a `kid` equal to +the **SHA-256 thumbprint** of its public key, so verifiers select the right key by `kid` rather +than assuming a single static key. + +To rotate the signing key **without breaking tokens already in the wild**: + +1. **Provision the new key** in the signing keystore under a new alias. +2. **Publish both keys.** Add the *old* alias to `gateway.signing.key.aliases.additional` in + `gateway-site.xml` so the JWKS endpoint serves both the old and new public keys: + + ```xml + + gateway.signing.key.aliases.additional + gateway-identity-previous + + ``` + +3. **Cut over signing** by pointing `gateway.signing.key.alias` at the new alias. New tokens are + now signed with the new key; verifiers still find the old key (by its `kid`) on JWKS for + tokens signed before the cutover. +4. **Retire the old key** once all tokens signed with it have expired: remove it from + `gateway.signing.key.aliases.additional`. + +Because clients resolve keys from JWKS by `kid`, no client reconfiguration is needed at any step. + +!!! tip "Order matters" + Publish the new key on JWKS *before* you start signing with it, and keep the old key published + *until* the last token it signed has expired. Overlapping the two windows is what makes the + rotation seamless. + +## Auditing + +KnoxIDF actions are recorded through Knox's standard audit framework, so KnoxIDF audit records +appear in the same audit log as the rest of the gateway (`$KNOX_HOME/logs/gateway-audit.log` by +default) and follow the gateway's configured audit layout. Security-relevant operations — token +issuance, client registration, consent decisions, federated login, and trusted-issuer +administration — are audited with the acting principal and outcome. + +Audit output is configured through the gateway's Log4j2 configuration +(`$KNOX_HOME/conf/gateway-log4j2.xml`), the same as every other Knox audit stream; see +[Audit](../config_audit.md) for audit-appender and retention configuration. + +## High availability + +KnoxIDF adds no HA mechanism of its own — it inherits Knox's standard HA model. Run multiple Knox +instances behind a load balancer as you would for any other Knox service, with two +KnoxIDF-specific requirements: + +- **Shared persistence.** All instances must point at the **same external database** + (`gateway.database.*`) so a federated identity created on one instance is visible on the others. + The embedded Derby default is per-process and is not suitable for multi-instance HA. +- **Consistent signing keys.** All instances must share the same signing keystore and the same + `gateway.signing.key.alias` / `gateway.signing.key.aliases.additional` configuration, so a token + issued by one instance verifies against the JWKS served by any instance. + +Sticky sessions are recommended for the interactive Authorization Code / federation flow so that +the browser stays on the instance holding the in-flight authorize/consent state, though the issued +tokens themselves are verifiable on any instance. + +## Rate limiting + +KnoxIDF does not implement rate limiting of its own, but it does not need to — Knox's +**`WebAppSec` provider** ships a rate-limiting filter you can attach to a KnoxIDF topology to +throttle request flooding, whether malicious or from a misconfigured client. This protects the +high-value token, authorization, and registration endpoints without any external infrastructure. + +Add the provider to the KnoxIDF topology and enable rate limiting: + +```xml + + webappsec + WebAppSec + true + + rate.limiting.enabled + true + + + rate.limiting.maxRequestsPerSec + 25 + + + + rate.limiting.delayMs + -1 + + +``` + +The filter tracks request rate per connection (or per session when +`rate.limiting.trackSessions=true`), delays or rejects requests over +`rate.limiting.maxRequestsPerSec`, and can exempt trusted callers via +`rate.limiting.ipWhitelist`. See the +[WebAppSec provider → Rate limiting](../config_webappsec_provider.md) documentation for the full +parameter set (`delayMs`, `maxWaitMs`, `throttledRequests`, `insertHeaders`, `ipWhitelist`, …). + +!!! note "Async support for non-rejecting modes" + A non-negative `rate.limiting.delayMs` (delay rather than reject) requires + `gateway.servlet.async.supported=true` in `gateway-site.xml` (it is `false` by default). + +You may still add rate limiting at the edge (load balancer / reverse proxy) as defense in depth. +Anonymous client registration in particular should either be left disabled (the default) or, when +enabled, fronted by the rate-limiting filter above. + +## Operational checklist + +- [ ] TLS enabled on every topology that exposes KnoxIDF endpoints. +- [ ] External database configured for any multi-instance / HA deployment. +- [ ] Signing keystore and `gateway.signing.key.alias*` identical across all instances. +- [ ] `knoxidf.client.registration.anonymous.allowed` reviewed (default `false`). +- [ ] `knoxidf.auto.consent.enabled` reviewed (default `false`). +- [ ] Federated OP client secrets stored as aliases, not plaintext. +- [ ] Trusted-issuer registry (`KNOXIDF_ADMIN`) exposed only on an administrator-restricted topology. +- [ ] Rate limiting enabled (WebAppSec provider on the topology, and/or at the edge) for the token / authorize / registration endpoints. +- [ ] Federated-OP back-channel timeouts (`gateway.knoxidf.federated.op.connect.timeout.ms` / `.read.timeout.ms`) reviewed for your OP. +- [ ] Audit log retention configured. diff --git a/knox-site/docs/knoxidf/security.md b/knox-site/docs/knoxidf/security.md new file mode 100644 index 0000000000..bd6f57bd97 --- /dev/null +++ b/knox-site/docs/knoxidf/security.md @@ -0,0 +1,218 @@ + + +# Security + +This chapter describes the security controls KnoxIDF enforces as an OAuth 2.0 / OIDC provider. +Understanding them is important when hardening a deployment and when reasoning about the trust +boundary between clients, Knox, and any external OIDC Providers. + +!!! warning "Always run over TLS" + OAuth 2.0 and OIDC assume a confidential channel. Every token-bearing endpoint must be + served over HTTPS in production. The `ssl.enabled=false` option is for local development + only. + +## Token-endpoint client authentication + +The token endpoint independently authenticates the client when redeeming an authorization code — +it does not rely on the upstream `JWTProvider` (which authenticates the *request* but does not +verify the OAuth `client_secret`). The check is chosen by what was stored at authorize time: + +- **PKCE path** — if the authorization request included a `code_challenge`, the client must + present a matching `code_verifier`. See [PKCE](#pkce). +- **Client-secret path** — if no `code_challenge` was stored, the client must present a valid + `client_secret`. + +The two paths are mutually exclusive, and there is no path where neither applies. The +`refresh_token` grant always requires a `client_secret` (there is no PKCE bypass for refresh). + +Client-secret verification is **constant-time**. The secret on the wire encodes the token id and +a passcode; Knox recomputes an HMAC (keyed by the `knox.token.hash.key` alias, over the token id, +issue time, and user name as a per-token salt) and compares it to the stored value with a +constant-time comparison. The embedded token id must equal the request `client_id`, binding the +secret to the specific client. + +## PKCE + +Only the **S256** code-challenge method is accepted. `plain` (and an omitted method, which OAuth +would otherwise default to `plain`) is **rejected** — both at the authorization endpoint (which +refuses to store a non-S256 challenge) and at the token endpoint (which refuses to verify with +any other method). This is enforced as defense in depth at both ends of the flow. The S256 +challenge is computed as `BASE64URL(SHA-256(ASCII(code_verifier)))` without padding, per +RFC 7636. + +Public clients (no `client_secret`) must use PKCE; this is how a public client proves possession +of the authorization code at redemption time. + +## Single-use authorization codes + +Authorization codes are single-use. The code is **atomically consumed before any token is +issued**, so of any number of concurrent redemptions of the same code, exactly one succeeds and +the rest receive `invalid_grant`. This closes the check-then-issue replay window. + +A code that fails *validation* (bad `redirect_uri`, wrong `client_id`, PKCE/secret failure) is +deliberately **not** consumed — this prevents a denial-of-service in which an attacker replays a +victim's code with bad parameters to burn it before the legitimate client redeems it. + +## Redirect-URI validation + +Open redirects are prevented both at registration and at authorization time. + +**At registration** (`/client/register`): + +- The **host** component may not contain a wildcard. +- **HTTPS is required.** Plain `http://` is accepted only for loopback hosts (`localhost`, + `127.0.0.1`, `::1`), per RFC 8252 for native apps. +- A wildcard `*` is permitted only at the **end of the path**, never in the host, query, or + fragment. + +**At authorization** (`/authorize`): + +- Non-wildcard URIs are matched by exact string equality. +- Wildcard URIs are matched by first comparing the **origin** (scheme + host + port) so that a + registered `https://good.example*` cannot match `https://good.example.evil.com`. Only then is + the path prefix compared, after both paths are `URI.normalize()`d — collapsing traversal + segments (e.g. `/callback/../admin`) so a raw prefix match cannot be tricked into escaping the + registered prefix. + +## Consent + +For the Authorization Code flow, KnoxIDF presents a [consent page](endpoints.md#consent-page) +where the user approves the scopes a client is requesting. Consent is **one-time per (user, +client, scopes)**: once granted, subsequent authorization requests for the same scopes proceed +without re-prompting. + +Whether consent can be skipped is a **server-side deployment decision**, governed by the +topology parameter `knoxidf.auto.consent.enabled`. It is read from the topology configuration at +startup and is **never** read from the incoming HTTP request — a client cannot bypass the consent +screen by sending an `auto_consent=true` parameter. + +Accepting consent is a **POST** and is **bound to the initiating subject**. Because acceptance +persists a consent record and issues an authorization code, it must not be reachable by passive +`GET` navigation, so the accept/deny endpoints are POST-only and the consent form posts directly +to them. In addition, `consentAccepted` verifies that the currently authenticated user is the same +subject that started the authorization request; a consent-URL replayed by a different authenticated +user is rejected with `403`. Without this binding, a leaked consent URL could otherwise record one +user's consent while routing an authorization code minted for a different user to the client's +`redirect_uri`. + +Consent records are stored as metadata on the client's token record, under a fixed-width key +derived as `"consent_"` + the first 20 hex characters of `SHA-256(subject)` (28 characters +total). Hashing the subject keeps the key within the storage column width regardless of how long +the username or federated UUID subject is, and the read and write paths derive the key +identically so they always agree. + +## Dynamic client registration + +Dynamic client registration is a deliberately supported deployment mode, but it is **not open by +default**. The endpoint refuses anonymous callers unless the topology explicitly sets: + +```xml + + knoxidf.client.registration.anonymous.allowed + true + +``` + +The default is **`false`** (secure by default). When open registration is enabled, the +token-endpoint client authentication described above is what still prevents a +registered-but-unauthenticated client from redeeming another client's authorization code. + +### Registerable-scope whitelist + +A client cannot self-assign an arbitrary scope at registration. The server bounds the scopes a +client may put in its `allowed_scopes` by a whitelist, so a client cannot register (and then mint +tokens carrying) a privileged scope name — e.g. `admin` — that a downstream service might trust. + +- The whitelist is configured with the topology parameter `knoxidf.registration.allowed.scopes` + (comma-separated). An explicit value is **authoritative**: it *replaces* the default rather than + extending it, so a narrower operator policy is honored exactly as written. +- When the parameter is **unset or blank**, the whitelist defaults to the **OIDC-standard scope + set** (`openid`, `profile`, `email`, `address`, `phone`, `offline_access`). This matches the + baseline registerable set of well-known OPs, so no standards-compliant client is rejected out of + the box, while a non-standard scope must be explicitly allowed. +- `openid` is **always** registerable regardless of the configured list (it is required in every + client's `allowed_scopes`). +- A registration request naming any scope outside the whitelist is rejected with + `invalid_scope`. A request that omits `allowed_scopes` receives the built-in defaults + **intersected with** the whitelist, so the default grant can never exceed operator policy. + +## Federated id_token validation + +When Knox brokers login to an external OIDC Provider, the OP's `id_token` is **fully validated +before any claim is trusted** — it is never decoded and trusted as-is. Validation fails closed at +each stage: + +1. **Signature + `exp`/`nbf`** — verified against the OP's JWKS (fetched from the configured + `jwks.endpoint`) using the configured signature algorithm (default `RS256`). +2. **Issuer** — must equal the statically configured `federated.op..issuer`, not a value + read from the token itself. +3. **Audience** — must contain the configured `federated.op..clientId` (Knox's client id + at the OP). +4. **Subject** — `sub` must be present and non-blank. +5. **Nonce** — the token's `nonce` must equal the nonce Knox generated for that login session, + binding the token to the specific authorization request. + +If the OP configuration is missing its `jwks.endpoint`, `issuer`, or `clientId`, validation is +refused outright — no OP token can be accepted. + +See [Federation](federation.md) for the full broker flow. + +## Trusted issuer registry + +The set of external issuers Knox will accept `id_token`s from is administered through the +[Trusted OIDC Issuers admin API](endpoints.md#trusted-oidc-issuers-admin) (`KNOXIDF_ADMIN` +role), which should be exposed only on an administrator-restricted topology. Registered issuer +URLs must be HTTPS, and there is a configurable upper bound on the number of trusted issuers. + +During token exchange, the JWKS URI resolved from a trusted issuer's discovery document is also +required to be HTTPS — a non-HTTPS `jwks_uri` is rejected and the exchange fails with `401`, so a +tampered discovery document cannot point key resolution at an attacker-controlled plaintext +endpoint. This check can be relaxed for development with +`knox.token.exchange.dynamic.jwks.allow.http=true` on the token-exchange `JWTProvider` (see the +[Configuration Reference](configuration.md#provider-related-properties-sample-topologies)). + +## Secret handling + +- **Federated OP client secrets** can be resolved from Knox's `AliasService` rather than being + written in plaintext in the topology. Set `federated.op..clientSecret.alias` to an alias + name; it takes precedence over the plaintext `clientSecret` parameter. If an alias is + configured but cannot be resolved, resolution **fails closed** — the request to the OP is not + made, rather than silently falling back to a plaintext value. +- **Federated access tokens are never persisted.** Only the federated *identity* (ID-token–derived + data) is stored; the OP's access token is discarded immediately after the token exchange. + Persisting an OP bearer token in plaintext token metadata would be a secret-at-rest exposure. + +## Subject derivation + +For federated users, the Knox `sub` is a deterministic **UUIDv5** over a fixed namespace UUID +(`6ba7b811-9dad-11d1-80b4-00c04fd430c8`, the RFC 4122 "URL" namespace) with the name +`issuer + "|" + subject`. The same upstream user therefore always maps to the same Knox subject +across logins and gateway restarts. + +!!! danger "Do not change the subject namespace" + Because the `sub` is derived from a fixed namespace UUID, changing that namespace would + rewrite the subject of **every** previously persisted federated user. The namespace is an + immutable part of the deployment's identity contract. + +## What is (and isn't) stored at rest + +KnoxIDF persists **only ID-token–derived federated identity data** — the core identity mapping +(Knox subject, provider, external subject, external issuer) and a filtered set of profile +attributes (`preferred_username`, `email`, `email_verified`, `given_name`, `family_name`, +`name`, `locale`). It does **not** store access tokens, refresh tokens, or OP client secrets. +This keeps the persisted footprint to what is needed for traceability and attribute reuse. diff --git a/knox-site/docs/service_ldap_server.md b/knox-site/docs/service_ldap_server.md index 8d524395b6..347c40aa89 100644 --- a/knox-site/docs/service_ldap_server.md +++ b/knox-site/docs/service_ldap_server.md @@ -44,6 +44,8 @@ The service is configured in `gateway-site.xml`. | `gateway.ldap.roles.lookup.strategy` | N/A | The LDAP roles lookup strategy (`file` or `rest`). | | `gateway.ldap.roles.lookup.rest.api.endpoint` | N/A | The LDAP roles lookup REST API endpoint. | | `gateway.ldap.roles.lookup.file.path` | N/A | The LDAP roles lookup file path. | +| `gateway.ldap.max.size.limit` | 1000 | The maximum size limit of the result set returned by search requests. | +| `gateway.ldap.max.time.limit` | 60000 | The maximum time limit for search requests in milliseconds. | ### Bind Credentials @@ -198,6 +200,10 @@ The proxy backend delegates lookups to a remote LDAP or Active Directory server. | `gateway.ldap.interceptor..groupMemberAttribute` | `memberUid` | Attribute used for group membership (e.g., `member` for AD). | | `gateway.ldap.interceptor..useMemberOf` | `false` | If `true`, use the `memberOf` attribute for efficient group lookups. | | `gateway.ldap.interceptor..proxy.poolMaxActive` | `8` | Maximum number of active connections in the pool. | +| `gateway.ldap.interceptor..pageSize` | `1000` | Page size for search requests. | +| `gateway.ldap.interceptor..maxResultSetSize` | `0` | Maximum number of results to return from a search, regardless of paging. 0 means unlimited. | + +NOTE: If this value is undefined and the interceptor was created by the KnoxLDAPServerManager, the KnoxLDAPServerManager will set the `gateway.ldap.interceptor..maxResultSetSize` value to be 1 greater than the proxy's `gateway.ldap.max.size.limit` configuration. This will ensure that the proxy returns a "Size limit exceeded" result if the backend has more results than the proxy's limit. ## Active Directory (AD) Integration diff --git a/knox-site/mkdocs.yml b/knox-site/mkdocs.yml index f147f0937e..46e02e2f86 100644 --- a/knox-site/mkdocs.yml +++ b/knox-site/mkdocs.yml @@ -114,6 +114,17 @@ nav: - General Troubleshooting: admin_troubleshooting.md - Authentication Issues: auth_troubleshooting.md - Service-Specific Issues: service_troubleshooting.md + - Identity Federation (OIDC Provider): + - Overview: knoxidf/index.md + - Getting Started: knoxidf/getting_started.md + - Endpoint Reference: knoxidf/endpoints.md + - Configuration Reference: knoxidf/configuration.md + - Security: knoxidf/security.md + - Federation: knoxidf/federation.md + - Operations: knoxidf/operations.md + - Integrations: + - Apache Polaris (Client Credentials): knoxidf/integrations/polaris.md + - Apache Polaris Console (Authorization Code): knoxidf/integrations/polaris_console.md - Developer Guide: - Overview: dev-guide/book.md - Extending Knox: @@ -134,7 +145,11 @@ markdown_extensions: permalink: true - admonition - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite diff --git a/pom.xml b/pom.xml index e0e43c6e66..c8e8535624 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,7 @@ gateway-service-metadata gateway-service-session gateway-openapi-ui + gateway-service-knoxidf @@ -164,7 +165,7 @@ 0.13 1.8.1 9.0 - 1.9.6 + 1.9.25.1 4.1.5 1.84 1.84 @@ -211,18 +212,17 @@ 4.0.5 2.10.1 1.9.0 - 4.0.29 + 5.0.4 32.1.3-jre 3.4.1 2.2 0.2 4.5.13 - 5.4.3 + 5.6.3 4.4.14 - 5.3.6 + 5.4.3 2.18.9 0.8.13 - 1.18 1.2.1 1.2.2 1.3.2 @@ -239,8 +239,8 @@ 3.4 2.47 9.4.57.v20241219 - 3.21.0 - 5.9.0 + 3.30.6 + 5.18.1 2.10.8 2.9.0 2.5.2 @@ -259,6 +259,7 @@ 4.1.135.Final 10.9.1 v22.20.0 + 11.37.2 4.12.0 5.2.2 6.5.3 @@ -271,9 +272,8 @@ 2.0.9 0.0.11.1 0.12.4 - 5.5.6 - 1.13.0 - 1.13.0 + 6.0.0 + 2.2.1 1.2.6 2.0.0 2.0.13 @@ -289,6 +289,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 +1429,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 +1449,11 @@ jersey-server ${jersey.version} + + org.glassfish.jersey.core + jersey-common + ${jersey.version} + org.glassfish.jersey.inject @@ -1505,6 +1516,11 @@ nimbus-jose-jwt ${nimbus-jose-jwt.version} + + com.nimbusds + oauth2-oidc-sdk + ${oauth2-oidc-sdk.version} + net.minidev @@ -1676,14 +1692,28 @@ - org.fusesource.jansi + org.jline jansi - ${jansi.version} + ${jline.version} + + + org.jline + jline-builtins + ${jline.version} + + + org.jline + jline-console + ${jline.version} + + + org.jline + jline-reader + ${jline.version} - org.jline - jline + jline-terminal ${jline.version} @@ -2043,6 +2073,11 @@ woodstox-core ${woodstox-core.version} + + com.fasterxml.uuid + java-uuid-generator + ${uuid.generator.version} + cglib @@ -2372,6 +2407,31 @@ shiro-web ${shiro.version} + + org.apache.shiro + shiro-config-core + ${shiro.version} + + + org.apache.shiro + shiro-cache + ${shiro.version} + + + org.apache.shiro + shiro-crypto-core + ${shiro.version} + + + org.apache.shiro + shiro-crypto-hash + ${shiro.version} + + + org.apache.shiro + shiro-lang + ${shiro.version} + org.ehcache