diff --git a/containers/test-apps/jwks-server/.gitignore b/containers/test-apps/jwks-server/.gitignore new file mode 100644 index 000000000..84c46696c --- /dev/null +++ b/containers/test-apps/jwks-server/.gitignore @@ -0,0 +1,19 @@ +/target +/classes +/checkouts +pom.xml +pom.xml.asc +lib/ +*.class +/.lein-* +/.nrepl-port +log/ + +# Intellij specific files +.idea +*.iml +*.ipr + +# test2junit output files +build.xml +test2junit/ diff --git a/containers/test-apps/jwks-server/README.md b/containers/test-apps/jwks-server/README.md new file mode 100644 index 000000000..616f212ee --- /dev/null +++ b/containers/test-apps/jwks-server/README.md @@ -0,0 +1,38 @@ +The jwks-server is a server to help with JWT access token authentication testing. +It supports two endpoints: `get-token` and `keys`. + +# Usage + +To run the server: +```bash +$ lein run +``` + +## Example: + +```bash +$ lein run 8080 resources/jwks.json resources/settings.edn +... +jwks-server.main - command-line arguments: [8080 resources/jwks.json resources/settings.edn] +jwks-server.main - port: 8080 +jwks-server.main - jwks file: resources/jwks.json +jwks-server.main - settings file: resources/settings.edn +jwks-server.main - starting server on port 8080 +... +eclipse.jetty.server.Server - Started @10846ms +``` + +# Build Uberjar + +```bash +$ lein uberjar +... +Created /path-to-waiter-jwks-server/target/uberjar/jwks-server-0.1.0-SNAPSHOT.jar +Created /path-to-waiter-jwks-server/target/uberjar/jwks-server-0.1.0-SNAPSHOT-standalone.jar +``` + +# JWKS keys + +The keys stored in [jwks.json](resources/jwks.json) were obtained as follows: +- EdDSA keys were generated [uisng nimbus library's OctetKeyPair](https://connect2id.com/blog/nimbus-jose-jwt-6). +- The RS256 key was obtained from [buddy tests](https://github.com/funcool/buddy-sign/blob/master/test/buddy/sign/jwk_tests.clj). \ No newline at end of file diff --git a/containers/test-apps/jwks-server/project.clj b/containers/test-apps/jwks-server/project.clj new file mode 100644 index 000000000..be1421811 --- /dev/null +++ b/containers/test-apps/jwks-server/project.clj @@ -0,0 +1,44 @@ +;; +;; Copyright (c) Two Sigma Open Source, LLC +;; +;; Licensed under the Apache License, Version 2.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. +;; +(defproject jwks-server "0.1.0-SNAPSHOT" + :dependencies [[buddy/buddy-sign "3.1.0"] + [cheshire "5.9.0"] + [org.clojure/clojure "1.10.1"] + [org.clojure/core.async "0.4.500"] + [org.clojure/data.json "0.2.6"] + [org.clojure/tools.logging "0.5.0"] + [org.slf4j/slf4j-log4j12 "1.7.28"] + [prismatic/plumbing "0.5.5"] + [ring/ring-core "1.7.1" + :exclusions [org.clojure/tools.reader]] + [twosigma/jet "0.7.10-20190831_055713-gf193d34" + :exclusions [org.eclipse.jetty/jetty-client + org.eclipse.jetty.alpn/alpn-api + org.eclipse.jetty.http2/http2-client + org.eclipse.jetty.websocket/websocket-client + org.eclipse.jetty/jetty-alpn-openjdk8-client + org.mortbay.jetty.alpn/alpn-boot]]] + :jvm-opts ["-server" + "-XX:+UseG1GC" + "-XX:MaxGCPauseMillis=50"] + :main ^:skip-aot jwks-server.main + :profiles {:uberjar {:aot :all}} + :resource-paths ["resources"] + :target-path "target/%s" + :test-selectors {:default (every-pred (complement :dev) (complement :integration)) + :dev :dev + :integration (every-pred :integration (complement :explicit))} + :uberjar-name ~(System/getenv "UBERJAR_NAME")) diff --git a/containers/test-apps/jwks-server/resources/jwks.json b/containers/test-apps/jwks-server/resources/jwks.json new file mode 100644 index 000000000..a99904ec7 --- /dev/null +++ b/containers/test-apps/jwks-server/resources/jwks.json @@ -0,0 +1,90 @@ +{ + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "kid": "7c368fc914ce6cb181fa0d670f63bd5df6db7b25", + "kty": "RSA", + "n": "vWmir2ZdXeMZkfsg0GTPfQw7CKmDNu50Sc76pndZPNyLf5JeR39JueHIPVXJ", + "use": "enc" + }, + { + "alg": "RS256", + "d": "Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97IjlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYTCBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLhBOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ", + "e": "AQAB", + "kid": "wwjcyqculjybrlzo0tzwjjniusfb4p4fakdotbf6", + "kty": "RSA", + "n": "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddxHmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMsD1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSHSXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ", + "use": "sig" + }, + { + "crv": "Ed25519", + "d": "8OSgWqVALOqZyLTLBcVJSgRZ6yxo0Wk_FkNG7uRCV28", + "kid": "f8f78b86-af1b-46c5-8849-006020f34d83", + "kty": "OKP", + "use": "sig", + "x": "H5UiUvUNr0OcxjEXpGCfFkYig-63Cs2SV_q6Y_5uDck" + }, + { + "crv": "Ed25519", + "d": "NIX7-o9XrTLAYNanapB9_fIeOnZUHug2jhwbZ6zs6dE", + "kid": "b66fd551-5e49-46e3-8c3b-06b953a16f6b", + "kty": "OKP", + "use": "sig", + "x": "gLYQSUzOu4T0xq12F_RCc51YgSc8w9GZK3WIyYRlH44" + }, + { + "crv": "Ed25519", + "d": "SXbPPwVHzPYYTwwRG_27RuoTN15Xn8zctwND727B2no", + "kid": "3106d31a-87a2-482f-a8dd-b727a043de5a", + "kty": "OKP", + "use": "sig", + "x": "qE4PwqfWP9pQ8iVpPhvQY433hiAn_rzjH-CPTnY_8yQ" + }, + { + "crv": "Ed25519", + "d": "dx_kwm1Kg47VwP-4DZHBO9bLdGgSu-w20VYwKyqHOw0", + "kid": "73711a87-eeb6-42be-b537-3fdeaa4872bb", + "kty": "OKP", + "use": "sig", + "x": "SycUT4WVy3AKTR_FxBjkAa468LocX8QrM480EK2EOoc" + }, + { + "crv": "Ed25519", + "d": "kdE8RVHsC87AdRSOA9jrJct75r-oZtggCgPQmpZHQzg", + "kid": "bfc58a16-8e07-42af-a632-2d3165dbf859", + "kty": "OKP", + "use": "sig", + "x": "nkKQj8rkxfvGFRcXNCeVz236ePxBZMtuxAp_F2PeIvk" + }, + { + "crv": "Ed25519", + "d": "oyi29vqepPO-ZCQLTnthB6-IWGME_88E3beFPwXK4Xs", + "kid": "e787c763-ffe1-405d-bdee-bc50ea0e3aa5", + "kty": "OKP", + "use": "sig", + "x": "nv4W45wfSuWBAKeY9hEJ_SLp1o3d0kdViCs9TpbsEWk" + }, + { + "crv": "Ed25519", + "d": "sH8QTNekLsfdXA29vipsLHiC8xQHqwteWcFtjM5gexs", + "kid": "9bf169b7-d8cc-4f60-82bd-2d060b11653b", + "kty": "OKP", + "use": "sig", + "x": "Tw32CCAlFNn2jz6wwVAQOgABuuA4pyLfaSnwzTEVhoY" + }, + { + "crv": "P-256", + "kid": "e787c763-ffe1-d8cc-b537-bc50ea0e3aa5", + "kty": "EC", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" + }, + { + "e": "AQAB", + "kid": "73711a87-eeb6-46e3-b537-2d060b11653b", + "kty": "RSA", + "n": "kWp2zRA23Z3vTL4uoe8kTFptxBVFunIoP4t_8TDYJrOb7D1iZNDXVeEsYKp6ppmrTZDAgd" + } + ] +} \ No newline at end of file diff --git a/containers/test-apps/jwks-server/resources/log4j.properties b/containers/test-apps/jwks-server/resources/log4j.properties new file mode 100644 index 000000000..fa644321e --- /dev/null +++ b/containers/test-apps/jwks-server/resources/log4j.properties @@ -0,0 +1,10 @@ +log4j.rootLogger=DEBUG, stdout + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{4} - %m%n +log4j.appender.stdout.Threshold=DEBUG + +# DEBUG is way too noisy for some categories +log4j.logger.org.eclipse.jetty=INFO diff --git a/containers/test-apps/jwks-server/resources/settings.edn b/containers/test-apps/jwks-server/resources/settings.edn new file mode 100644 index 000000000..ed13fde2c --- /dev/null +++ b/containers/test-apps/jwks-server/resources/settings.edn @@ -0,0 +1,3 @@ +{:issuer "test.com" + :subject-key :sub + :token-type "JWT"} \ No newline at end of file diff --git a/containers/test-apps/jwks-server/src/jwks_server/config.clj b/containers/test-apps/jwks-server/src/jwks_server/config.clj new file mode 100644 index 000000000..91e778fd6 --- /dev/null +++ b/containers/test-apps/jwks-server/src/jwks_server/config.clj @@ -0,0 +1,37 @@ +;; +;; Copyright (c) Two Sigma Open Source, LLC +;; +;; Licensed under the Apache License, Version 2.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. +;; +(ns jwks-server.config + (:require [clojure.tools.logging :as log])) + +(let [settings-promise (promise)] + (defn initialize-settings + [settings] + (log/info "settings:" settings) + (deliver settings-promise settings)) + + (defn retrieve-settings + [] + (deref settings-promise 0 {}))) + +(let [jwks-promise (promise)] + (defn initialize-jwks + [jwks] + (log/info "num jwks entries:" (-> jwks :keys count)) + (deliver jwks-promise jwks)) + + (defn retrieve-jwks + [] + (deref jwks-promise 0 {}))) diff --git a/containers/test-apps/jwks-server/src/jwks_server/handler.clj b/containers/test-apps/jwks-server/src/jwks_server/handler.clj new file mode 100644 index 000000000..e0fd71241 --- /dev/null +++ b/containers/test-apps/jwks-server/src/jwks_server/handler.clj @@ -0,0 +1,115 @@ +;; +;; Copyright (c) Two Sigma Open Source, LLC +;; +;; Licensed under the Apache License, Version 2.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. +;; +(ns jwks-server.handler + (:require [buddy.core.keys :as buddy-keys] + [buddy.sign.jwt :as jwt] + [clojure.data.json :as json] + [clojure.tools.logging :as log] + [clojure.string :as str] + [jwks-server.config :as config] + [plumbing.core :as pc] + [ring.middleware.params :as ring-params] + [ring.util.request :as ring-request])) + +(defn prepare-response + "Prepares and returns a standard response" + ([{:keys [query-string request-method uri]} message status] + (prepare-response (cond-> {"message" message + "request-method" request-method + "uri" uri} + (not (str/blank? query-string)) + (assoc "query-string" query-string)) + status)) + ([data-map status] + {:body (json/write-str data-map) + :headers {"content-type" "application/json" + "server" "jwks-server"} + :status status})) + +(defn generate-jwt-access-token + "Generates the JWT access token using the provided private key." + [alg jwk-entry payload header] + (let [private-key (-> jwk-entry pc/keywordize-map buddy-keys/jwk->private-key) + options {:alg alg :header header}] + (jwt/sign payload private-key options))) + +(defn- request->query-params + "Like Ring's params-request, but doesn't try to pull params from the body." + [request] + (->> (or (ring-request/character-encoding request) "UTF-8") + (ring-params/assoc-query-params request) + :query-params)) + +(defn process-get-token-request + "Retrieves an JWT access token generated using a random EdDSA key." + [{:keys [query-string] :as request}] + (log/info "query string:" query-string) + (let [{:strs [host]} (request->query-params request) + _ (when (str/blank? host) + (throw (ex-info "host query parameter not provided" {:status 400}))) + {:keys [keys]} (config/retrieve-jwks) + eddsa-keys (filter (fn [{:keys [crv]}] (= "Ed25519" crv)) keys) + {:keys [kid] :as entry} (rand-nth eddsa-keys) + _ (log/info "selected kid:" kid) + principal (System/getProperty "user.name") + _ (log/info "principal:" principal) + {:keys [issuer subject-key token-type]} (config/retrieve-settings) + subject-key (keyword subject-key) + expiry-time-secs (+ (long (/ (System/currentTimeMillis) 1000)) 600) + payload (cond-> {:aud (str host) :exp expiry-time-secs :iss issuer :sub principal} + (not= :sub subject-key) (assoc subject-key principal)) + header {:kid kid :typ token-type} + access-token (generate-jwt-access-token :eddsa entry payload header)] + (prepare-response + {"access_token" access-token + "kid" kid + "issuer" issuer + "principal" principal + "realm" host + "subject-key" subject-key + "token-type" token-type} + 200))) + +(defn process-get-keys-request + "Returns the JWKS managed bu this server." + [_] + (prepare-response + (update (config/retrieve-jwks) + :keys + (fn [keys] + (map (fn [entry] (dissoc entry :d)) keys))) + 200)) + +(defn request-handler + "Factory for the ring request handler." + [{:keys [headers request-method uri] :as request}] + (log/info "received" request-method "request as path" uri) + (log/info "request headers:" headers) + (try + (cond + (= uri "/get-token") + (if (= request-method :get) + (process-get-token-request request) + (prepare-response request "method not allowed" 405)) + (= uri "/keys") + (if (= request-method :get) + (process-get-keys-request request) + (prepare-response request "method not allowed" 405)) + :else + (prepare-response request "unsupported endpoint" 404)) + (catch Throwable th + (log/error th "error in processing request") + (prepare-response request (.getMessage th) (:status (ex-data th) 500))))) diff --git a/containers/test-apps/jwks-server/src/jwks_server/main.clj b/containers/test-apps/jwks-server/src/jwks_server/main.clj new file mode 100644 index 000000000..6914f6619 --- /dev/null +++ b/containers/test-apps/jwks-server/src/jwks_server/main.clj @@ -0,0 +1,80 @@ +;; +;; Copyright (c) Two Sigma Open Source, LLC +;; +;; Licensed under the Apache License, Version 2.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. +;; +(ns jwks-server.main + (:require [clojure.data.json :as json] + [clojure.edn :as edn] + [clojure.tools.logging :as log] + [jwks-server.config :as config] + [jwks-server.handler :as handler] + [plumbing.core :as pc] + [qbits.jet.server :as server]) + (:gen-class)) + +(defn- setup-exception-handler + "Sets up the UncaughtExceptionHandler." + [] + (Thread/setDefaultUncaughtExceptionHandler + (reify Thread$UncaughtExceptionHandler + (uncaughtException [_ thread throwable] + (log/error throwable (str (.getName thread) " threw exception: " (.getMessage throwable))))))) + +(defn exit + "Helper function that prints the message and triggers a System exit." + [status message] + (if (zero? status) + (log/info message) + (log/error message)) + (System/exit status)) + +(defn start-server + "Starts the JWKS server on the specified port." + [port] + (log/info "starting server on port" port) + (server/run-jetty {:host "127.0.0.1" + :join? false + :port port + :ring-handler handler/request-handler + :send-server-version? false})) + +(defn -main + "The main entry point." + [& args] + (setup-exception-handler) + (try + (log/info "command-line arguments:" (vec args)) + (when (< (count args) 3) + (exit 1 "usage: port jwks-file settings-file")) + (let [port (nth args 0) + jwks-file (nth args 1) + settings-file (nth args 2)] + (when-not port + (exit 1 "No port specified as first argument on the command-line")) + (when-not jwks-file + (exit 1 "No jwks file specified as second argument on the command-line")) + (when-not settings-file + (exit 1 "No settings file specified as third argument on the command-line")) + (log/info "port:" port) + (log/info "jwks file:" jwks-file) + (log/info "settings file:" settings-file) + (let [port-int (Integer/parseInt port) + jwks (-> jwks-file slurp json/read-str pc/keywordize-map) + settings (-> settings-file slurp edn/read-string)] + (config/initialize-jwks jwks) + (config/initialize-settings settings) + (start-server port-int))) + (catch Exception e + (log/error e "error in initializing jwks server") + (exit 1 (str "encountered error running jwks-server: " (.getMessage e)))))) diff --git a/waiter/bin/ci/jwks-server-setup.sh b/waiter/bin/ci/jwks-server-setup.sh new file mode 100755 index 000000000..fa7e189ab --- /dev/null +++ b/waiter/bin/ci/jwks-server-setup.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Usage: jwks-server-setup.sh [JWKS_PORT] +# +# Examples: +# jwks-server-setup.sh 9040 +# +# Run a JSON Web Key Set (JWKS) server that returns a fixed set of keys. +# JWKS retrieval request can be routed to: http://localhost:JWKS_PORT/keys +# When the JWKS_PORT is not specified, a default of 8040 is used. + +set -e + +JWKS_PORT=${1:-8040} + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +WAITER_DIR=${DIR}/../.. +JWT_DIR=${WAITER_DIR}/../containers/test-apps/jwks-server + +echo "Starting JWKS server on port ${JWKS_PORT}" +( pushd ${JWT_DIR} && lein run ${JWKS_PORT} resources/jwks.json resources/settings.edn && popd ) & + +echo "Waiting for JWKS server..." +while ! curl -k http://127.0.0.1:${JWKS_PORT}/keys &>/dev/null; do + echo -n . + sleep 3 +done +echo +echo "JWKS server started successfully" diff --git a/waiter/bin/ci/run-integration-tests-composite-scheduler.sh b/waiter/bin/ci/run-integration-tests-composite-scheduler.sh index 4d9b54657..46a5e3afb 100755 --- a/waiter/bin/ci/run-integration-tests-composite-scheduler.sh +++ b/waiter/bin/ci/run-integration-tests-composite-scheduler.sh @@ -43,6 +43,12 @@ if [[ $TEST_SELECTOR =~ fast$ ]]; then ${DIR}/saml-idp-server-setup.sh fi +# start the JWKS server +JWKS_PORT=6666 +${WAITER_DIR}/bin/ci/jwks-server-setup.sh ${JWKS_PORT} +export JWKS_SERVER_URL="http://127.0.0.1:${JWKS_PORT}/keys" +export WAITER_TEST_JWT_ACCESS_TOKEN_URL="http://127.0.0.1:${JWKS_PORT}/get-token?host={HOST}" + # Start waiter ${WAITER_DIR}/bin/run-using-composite-scheduler.sh ${WAITER_PORT} & diff --git a/waiter/bin/ci/run-integration-tests-k8s-scheduler.sh b/waiter/bin/ci/run-integration-tests-k8s-scheduler.sh index 218fcb597..8b9fc6544 100755 --- a/waiter/bin/ci/run-integration-tests-k8s-scheduler.sh +++ b/waiter/bin/ci/run-integration-tests-k8s-scheduler.sh @@ -29,6 +29,12 @@ export WAITER_S3_BUCKET=http://$S3SERVER_IP:8000/waiter-service-logs # Ensure we have the docker image for the pods ${CONTAINERS_DIR}/bin/build-docker-images.sh +# start the JWKS server +JWKS_PORT=6666 +${WAITER_DIR}/bin/ci/jwks-server-setup.sh ${JWKS_PORT} +export JWKS_SERVER_URL="http://127.0.0.1:${JWKS_PORT}/keys" +export WAITER_TEST_JWT_ACCESS_TOKEN_URL="http://127.0.0.1:${JWKS_PORT}/get-token?host={HOST}" + # Start waiter : ${WAITER_PORT:=9091} ${WAITER_DIR}/bin/run-using-k8s.sh ${WAITER_PORT} & diff --git a/waiter/bin/ci/run-integration-tests-marathon-scheduler.sh b/waiter/bin/ci/run-integration-tests-marathon-scheduler.sh index ba0636e35..f84084758 100755 --- a/waiter/bin/ci/run-integration-tests-marathon-scheduler.sh +++ b/waiter/bin/ci/run-integration-tests-marathon-scheduler.sh @@ -29,6 +29,12 @@ if [ -n "$CONTINUOUS_INTEGRATION" ]; then ${MINIMESOS_CMD} up popd + # start the JWKS server + JWKS_PORT=6666 + ${WAITER_DIR}/bin/ci/jwks-server-setup.sh ${JWKS_PORT} + export JWKS_SERVER_URL="http://127.0.0.1:${JWKS_PORT}/keys" + export WAITER_TEST_JWT_ACCESS_TOKEN_URL="http://127.0.0.1:${JWKS_PORT}/get-token?host={HOST}" + # Start waiter ${WAITER_DIR}/bin/run-using-minimesos.sh ${WAITER_PORT} & fi diff --git a/waiter/config-composite.edn b/waiter/config-composite.edn index 7d72a82dd..5a71fad44 100644 --- a/waiter/config-composite.edn +++ b/waiter/config-composite.edn @@ -34,7 +34,16 @@ ; ---------- Security ---------- - :authenticator-config {:kind :composite + :authenticator-config {:jwt {:http-options {:conn-timeout 10000 + :socket-timeout 10000 + :spnego-auth false} + :issuer "test.com" + :jwks-url #config/env "JWKS_SERVER_URL" + :subject-key :sub + :supported-algorithms #{:eddsa :rs256} + :token-type "JWT" + :update-interval-ms 60000} + :kind :composite :composite {:factory-fn waiter.auth.composite/composite-authenticator :authentication-providers {"one-user" {:factory-fn waiter.auth.authentication/one-user-authenticator ;; The user account used to launch services: diff --git a/waiter/config-full.edn b/waiter/config-full.edn index 24de5b27b..7c5e02b25 100644 --- a/waiter/config-full.edn +++ b/waiter/config-full.edn @@ -218,6 +218,27 @@ ;; The authentication scheme to use if one is not specified :default-authentication-provider "kerberos"} + ;; Waiter supports JWT access token-based authentication before trying the configured authenticator. + ;; JWT authentication can be disabled by specifying a value of :disabled instead of configuring the map below: + ;; :jwt :disabled + :jwt {;; JWT relies on periodically refreshing the list of available keys from a JWKS + :http-options {;; The HTTP options that will be used when accessing the authorization server: + :conn-timeout 10000 + :socket-timeout 10000 + :spnego-auth false} + ;; The issuer expected on the access token claims + :issuer "test.com" + ;; url of the authorization server where the public keys (JWKS) are available: + :jwks-url "http://127.0.0.1:8040/jwks" + ;; The keyword used to retrieve the subject from the access token claims + :subject-key :sub + ;; The supported algorithms while validating access tokens, must be subset of #{:eddsa :rs256} + :supported-algorithms #{:eddsa} + ;; The token type expected in the access token header + :token-type "JWT" + ;; The interval at which to refresh the keys from the authorization server: + :update-interval-ms 60000} + ;; :kind :one-user allows anonymous access to services (for testing purposes only): :one-user {;; Custom implementations should specify a :factory-fn ;; that returns an instance of waiter.auth.authentication.Authenticator: diff --git a/waiter/config-k8s.edn b/waiter/config-k8s.edn index 56ef7c15e..ef73c711f 100644 --- a/waiter/config-k8s.edn +++ b/waiter/config-k8s.edn @@ -27,7 +27,16 @@ ; ---------- Security ---------- - :authenticator-config {:kind :one-user + :authenticator-config {:jwt {:http-options {:conn-timeout 10000 + :socket-timeout 10000 + :spnego-auth false} + :issuer "test.com" + :jwks-url #config/env "JWKS_SERVER_URL" + :subject-key :sub + :supported-algorithms #{:eddsa :rs256} + :token-type "JWT" + :update-interval-ms 60000} + :kind :one-user :one-user {;; The user account used to launch services: :run-as-user #config/env "WAITER_AUTH_RUN_AS_USER"}} diff --git a/waiter/config-minimal.edn b/waiter/config-minimal.edn index 195888c89..396652117 100644 --- a/waiter/config-minimal.edn +++ b/waiter/config-minimal.edn @@ -12,8 +12,7 @@ ; ---------- Security ---------- - :authenticator-config {:one-user { - ;; the user account used to launch services + :authenticator-config {:one-user {;; the user account used to launch services :run-as-user "launch-username"}} ; ---------- Scheduling ---------- diff --git a/waiter/config-minimesos.edn b/waiter/config-minimesos.edn index 5be7c5b46..cfc8b64d9 100644 --- a/waiter/config-minimesos.edn +++ b/waiter/config-minimesos.edn @@ -26,7 +26,16 @@ ; ---------- Security ---------- - :authenticator-config {:kind :one-user + :authenticator-config {:jwt {:http-options {:conn-timeout 10000 + :socket-timeout 10000 + :spnego-auth false} + :issuer "test.com" + :jwks-url #config/env "JWKS_SERVER_URL" + :subject-key :sub + :supported-algorithms #{:eddsa :rs256} + :token-type "JWT" + :update-interval-ms 60000} + :kind :one-user :one-user {;; The user account used to launch services: :run-as-user #config/env "WAITER_AUTH_RUN_AS_USER"}} diff --git a/waiter/config-shell.edn b/waiter/config-shell.edn index 6fda54755..8f1008832 100644 --- a/waiter/config-shell.edn +++ b/waiter/config-shell.edn @@ -17,7 +17,8 @@ ; ---------- Security ---------- - :authenticator-config {:kind :one-user + :authenticator-config {:jwt :disabled + :kind :one-user :one-user {;; The user account used to launch services: :run-as-user #config/env "WAITER_AUTH_RUN_AS_USER"}} diff --git a/waiter/integration/waiter/authentication_test.clj b/waiter/integration/waiter/authentication_test.clj index be6da5aa6..6f42ffc16 100644 --- a/waiter/integration/waiter/authentication_test.clj +++ b/waiter/integration/waiter/authentication_test.clj @@ -3,9 +3,11 @@ [clojure.data.json :as json] [clojure.string :as string] [clojure.test :refer :all] + [clojure.tools.logging :as log] [reaver :as reaver] - [waiter.util.client-tools :refer :all]) - (:import (java.net URL URLEncoder))) + [waiter.util.client-tools :refer :all] + [waiter.util.utils :as utils]) + (:import (java.net URI URL URLEncoder))) (deftest ^:parallel ^:integration-fast test-default-composite-authenticator (testing-using-waiter-url @@ -126,3 +128,218 @@ (finally (delete-token-and-assert waiter-url token))))))) +(defn- retrieve-access-token + [realm] + (if-let [access-token-url-env (System/getenv "WAITER_TEST_JWT_ACCESS_TOKEN_URL")] + (let [access-token-url (string/replace access-token-url-env "{HOST}" realm) + access-token-uri (URI. access-token-url) + protocol (.getScheme access-token-uri) + authority (.getAuthority access-token-uri) + path (str (.getPath access-token-uri) "?" (.getQuery access-token-uri)) + access-token-response (make-request authority path :headers {"x-iam" "waiter"} :protocol protocol) + _ (assert-response-status access-token-response 200) + access-token-response-json (-> access-token-response :body str json/read-str)] + (get access-token-response-json "access_token")) + (throw (ex-info "WAITER_TEST_JWT_ACCESS_TOKEN_URL environment variable has not been provided" {})))) + +(defmacro assert-auth-cookie + "Helper macro to assert the value of the set-cookie header." + [set-cookie assertion-message] + `(let [set-cookie# ~set-cookie + assertion-message# ~assertion-message] + (is (string/includes? set-cookie# "x-waiter-auth=") assertion-message#) + (is (string/includes? set-cookie# "Max-Age=") assertion-message#) + (is (string/includes? set-cookie# "Path=/") assertion-message#) + (is (string/includes? set-cookie# "HttpOnly=true") assertion-message#))) + +(deftest ^:parallel ^:integration-fast test-successful-jwt-authentication-waiter-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + access-token (retrieve-access-token waiter-host) + request-headers {"authorization" (str "Bearer " access-token) + "host" waiter-host + "x-forwarded-proto" "https"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [body headers] :as response} + (make-request target-url "/waiter-auth" :disable-auth true :headers request-headers :method :get) + set-cookie (str (get headers "set-cookie")) + assertion-message (str {:headers headers + :set-cookie set-cookie + :target-url target-url})] + (assert-response-status response 200) + (is (= (retrieve-username) (str body))) + (is (= "jwt" (get headers "x-waiter-auth-method")) assertion-message) + (is (= (retrieve-username) (get headers "x-waiter-auth-user")) assertion-message) + (assert-auth-cookie set-cookie assertion-message)) + (log/info "JWT authentication is disabled")))) + +(deftest ^:parallel ^:integration-fast test-forbidden-jwt-authentication-waiter-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + access-token (retrieve-access-token waiter-host) + request-headers {"authorization" (str "Bearer " access-token) + "host" waiter-host + "x-forwarded-proto" "http"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [body headers] :as response} + (make-request target-url "/waiter-auth" :disable-auth true :headers request-headers :method :get) + set-cookie (str (get headers "set-cookie")) + assertion-message (str {:headers headers + :target-url target-url})] + (assert-response-status response 403) + (is (string/includes? (str body) "Must use HTTPS connection") assertion-message) + (is (string/blank? set-cookie) assertion-message)) + (log/info "JWT authentication is disabled")))) + +(deftest ^:parallel ^:integration-fast test-forbidden-authentication-with-bad-jwt-token-waiter-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + access-token (str (retrieve-access-token waiter-host) "invalid") + request-headers {"authorization" [(str "Bearer " access-token) + (str "Negotiate bad-token") + (str "SingleUser forbidden")] + "host" waiter-host + "x-forwarded-proto" "https"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [headers] :as response} + (make-request target-url "/waiter-auth" :disable-auth true :headers request-headers :method :get) + set-cookie (str (get headers "set-cookie")) + assertion-message (str (select-keys response [:body :error :headers :status]))] + (assert-response-status response 403) + (is (string/blank? (get headers "www-authenticate")) assertion-message) + (is (string/blank? set-cookie) assertion-message)) + (log/info "JWT authentication is disabled")))) + +(deftest ^:parallel ^:integration-fast test-unauthorized-jwt-authentication-waiter-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + access-token (str (retrieve-access-token waiter-host) "invalid") + request-headers {"authorization" [(str "Bearer " access-token) + ;; absence of Negotiate header also trigger an unauthorized response + (str "SingleUser unauthorized")] + "host" waiter-host + "x-forwarded-proto" "https"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [headers] :as response} + (make-request target-url "/waiter-auth" :disable-auth true :headers request-headers :method :get) + set-cookie (str (get headers "set-cookie")) + assertion-message (str (select-keys response [:body :error :headers :status]))] + (assert-response-status response 401) + (is (string/blank? set-cookie) assertion-message) + (if-let [challenge (get headers "www-authenticate")] + (do + (is (string/includes? (str challenge) "Bearer realm")) + (is (> (count (string/split challenge #",")) 1) assertion-message)) + (is false (str "www-authenticate header missing: " assertion-message)))) + (log/info "JWT authentication is disabled")))) + +(deftest ^:parallel ^:integration-fast test-fallback-to-alternate-auth-on-invalid-jwt-token-waiter-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + access-token (str (retrieve-access-token waiter-host) "invalid") + request-headers {"authorization" (str "Bearer " access-token) + "host" waiter-host + "x-forwarded-proto" "https"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [body headers] :as response} + (make-request target-url "/waiter-auth" :headers request-headers :method :get) + set-cookie (str (get headers "set-cookie")) + assertion-message (str {:headers headers + :set-cookie set-cookie + :target-url target-url})] + (assert-response-status response 200) + (is (= (retrieve-username) (str body))) + (let [{:strs [x-waiter-auth-method]} headers] + (is (not= "jwt" x-waiter-auth-method) assertion-message) + (is (not (string/blank? x-waiter-auth-method)) assertion-message)) + (is (= (retrieve-username) (get headers "x-waiter-auth-user")) assertion-message) + (assert-auth-cookie set-cookie assertion-message)) + (log/info "JWT authentication is disabled")))) + +(defn- create-token-name + [waiter-url service-id-prefix] + (str service-id-prefix "." (subs waiter-url 0 (string/index-of waiter-url ":")))) + +(deftest ^:parallel ^:integration-fast test-jwt-authentication-token-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + host (create-token-name waiter-url (rand-name)) + service-parameters (assoc (kitchen-params) :name (rand-name)) + token-response (post-token waiter-url (assoc service-parameters + :run-as-user (retrieve-username) + "token" host)) + _ (assert-response-status token-response 200) + access-token (retrieve-access-token host) + request-headers {"authorization" (str "Bearer " access-token) + "host" host + "x-forwarded-proto" "https"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [headers service-id] :as response} + (make-request-with-debug-info + request-headers + #(make-request target-url "/status" :disable-auth true :headers % :method :get)) + set-cookie (str (get headers "set-cookie")) + assertion-message (str {:headers headers + :service-id service-id + :set-cookie set-cookie + :target-url target-url})] + (try + (with-service-cleanup + service-id + (assert-response-status response 200) + (is (= "jwt" (get headers "x-waiter-auth-method")) assertion-message) + (is (= (retrieve-username) (get headers "x-waiter-auth-user")) assertion-message) + (assert-auth-cookie set-cookie assertion-message)) + (finally + (delete-token-and-assert waiter-url host)))) + (log/info "JWT authentication is disabled")))) + +(deftest ^:parallel ^:integration-fast test-fallback-to-alternate-auth-on-invalid-jwt-token-token-realm + (testing-using-waiter-url + (if (jwt-auth-enabled? waiter-url) + (let [waiter-host (-> waiter-url sanitize-waiter-url utils/authority->host) + host (create-token-name waiter-url (rand-name)) + service-parameters (assoc (kitchen-params) :name (rand-name)) + token-response (post-token waiter-url (assoc service-parameters + :run-as-user (retrieve-username) + "token" host)) + _ (assert-response-status token-response 200) + access-token (str (retrieve-access-token host) "invalid") + request-headers {"authorization" (str "Bearer " access-token) + "host" host + "x-forwarded-proto" "https"} + {:keys [port]} (waiter-settings waiter-url) + target-url (str waiter-host ":" port) + {:keys [headers service-id] :as response} + (make-request-with-debug-info + request-headers + #(make-request target-url "/status" :headers % :method :get)) + set-cookie (str (get headers "set-cookie")) + assertion-message (str {:headers headers + :service-id service-id + :set-cookie set-cookie + :target-url target-url})] + (try + (with-service-cleanup + service-id + (assert-response-status response 200) + (let [{:strs [x-waiter-auth-method]} headers] + (is (not= "jwt" x-waiter-auth-method) assertion-message) + (is (not (string/blank? x-waiter-auth-method)) assertion-message)) + (is (= (retrieve-username) (get headers "x-waiter-auth-user")) assertion-message) + (assert-auth-cookie set-cookie assertion-message)) + (finally + (delete-token-and-assert waiter-url host)))) + (log/info "JWT authentication is disabled")))) diff --git a/waiter/project.clj b/waiter/project.clj index 184c4ae62..7d5839ccf 100644 --- a/waiter/project.clj +++ b/waiter/project.clj @@ -32,6 +32,10 @@ :dependencies [[bidi "2.1.5" :exclusions [prismatic/schema ring/ring-core]] + [buddy/buddy-sign "3.1.0" + :exclusions [[commons-codec]]] + ;; resolve the cheshire dependency used by buddy and jet + [cheshire "5.8.1"] [twosigma/courier "1.5.14" :exclusions [com.google.guava/guava io.grpc/grpc-core] :scope "test"] diff --git a/waiter/src/waiter/auth/authentication.clj b/waiter/src/waiter/auth/authentication.clj index 1a62c710e..c7ab9d585 100644 --- a/waiter/src/waiter/auth/authentication.clj +++ b/waiter/src/waiter/auth/authentication.clj @@ -19,7 +19,8 @@ [clojure.string :as str] [clojure.tools.logging :as log] [waiter.cookie-support :as cookie-support] - [waiter.middleware :as middleware])) + [waiter.middleware :as middleware] + [waiter.util.utils :as utils])) (def ^:const AUTH-COOKIE-NAME "x-waiter-auth") @@ -123,6 +124,15 @@ [cookie-string] (cookie-support/remove-cookie cookie-string AUTH-COOKIE-NAME)) +(defn select-auth-header + "Filters and return the first authorization header that passes the predicate." + [{:keys [headers]} predicate] + (let [{:strs [authorization]} headers + auth-headers (if (string? authorization) + (str/split (str authorization) #",") + authorization)] + (some #(when (predicate %) %) auth-headers))) + ;; An anonymous request does not contain any authentication information. ;; This is equivalent to granting everyone access to the resource. ;; The anonymous authenticator attaches the principal of run-as-user to the request. @@ -134,13 +144,29 @@ (defrecord SingleUserAuthenticator [run-as-user password] Authenticator (wrap-auth-handler [_ request-handler] - (fn anonymous-handler [request] - (let [auth-params-map (auth-params-map :single-user run-as-user run-as-user)] - (handle-request-auth request-handler request run-as-user auth-params-map password nil))))) + (let [single-user-prefix "SingleUser "] + (fn anonymous-handler [request] + (let [auth-header (select-auth-header request #(str/starts-with? % single-user-prefix)) + auth-path (when auth-header + (str/trim (subs auth-header (count single-user-prefix))))] + (cond + (str/blank? auth-path) + (handle-request-auth request-handler request :single-user run-as-user password) + (= "unauthorized" auth-path) + (utils/attach-waiter-source + {:headers {"www-authenticate" "SingleUser"} :status 401}) + (= "forbidden" auth-path) + (utils/attach-waiter-source + {:headers {} :status 403}) + :else + (utils/attach-waiter-source + {:headers {"x-waiter-single-user" (str "unknown operation: " auth-path)} :status 400}))))))) (defn one-user-authenticator "Factory function for creating single-user authenticator" [{:keys [password run-as-user]}] + {:pre [(some? password) + (not (str/blank? run-as-user))]} (log/warn "use of single-user authenticator is strongly discouraged for production use:" "requests will use principal" run-as-user) (->SingleUserAuthenticator run-as-user password)) diff --git a/waiter/src/waiter/auth/jwt.clj b/waiter/src/waiter/auth/jwt.clj new file mode 100644 index 000000000..747ee30ea --- /dev/null +++ b/waiter/src/waiter/auth/jwt.clj @@ -0,0 +1,363 @@ +;; +;; Copyright (c) Two Sigma Open Source, LLC +;; +;; Licensed under the Apache License, Version 2.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. +;; +(ns waiter.auth.jwt + (:require [buddy.core.keys :as buddy-keys] + [buddy.sign.jwe :as jwe] + [buddy.sign.jwt :as jwt] + [clj-time.coerce :as tc] + [clj-time.core :as t] + [clojure.data.json :as json] + [clojure.set :as set] + [clojure.string :as str] + [clojure.tools.logging :as log] + [clojure.walk :as walk] + [metrics.counters :as counters] + [metrics.timers :as timers] + [plumbing.core :as pc] + [waiter.auth.authentication :as auth] + [waiter.metrics :as metrics] + [waiter.util.date-utils :as du] + [waiter.util.http-utils :as hu] + [waiter.util.ring-utils :as ru] + [waiter.util.utils :as utils]) + (:import (clojure.lang ExceptionInfo))) + +(defn eddsa-key? + "Returns true if the JWKS entry represents an EDSA key." + [{:keys [crv kid kty use x]}] + (and (= "Ed25519" crv) + (= "OKP" kty) + (= "sig" use) + (not (str/blank? kid)) + (not (str/blank? x)))) + +(defn rs256-key? + "Returns true if the JWKS entry represents an RSA256 key." + [{:keys [e kid kty n use]}] + (and (= "AQAB" e) + (= "RSA" kty) + (= "sig" use) + (not (str/blank? kid)) + (not (str/blank? n)))) + +(defn supported-key? + "Returns true if the JWKS entry represents a supported key." + [supported-algorithms entry] + (or (and (some #(= % :eddsa) supported-algorithms) (eddsa-key? entry)) + (and (some #(= % :rs256) supported-algorithms) (rs256-key? entry)))) + +(defn retrieve-public-key + "Returns the EdDSAPublicKey public key from the provided string." + [entry] + (metrics/with-timer! + (metrics/waiter-timer "core" "jwt" "key-creation") + (constantly true) + (buddy-keys/jwk->public-key entry))) + +(defn attach-public-key + "Attaches the EdDSA public key into the provided entries." + [entry] + (assoc entry ::public-key (retrieve-public-key entry))) + +(defn retrieve-jwks-with-retries + "Retrieves the JWKS using the specified url. + JWKS retrieval tried retry-limit times at intervals on retry-interval-ms ms when there is an error." + [http-client url + {:keys [retry-interval-ms retry-limit] + :or {retry-interval-ms 100 + retry-limit 2} + :as options}] + (let [with-retries (utils/retry-strategy + {:delay-multiplier 1.0, :initial-delay-ms retry-interval-ms, :max-retries retry-limit}) + http-options (dissoc options :retry-interval-ms :retry-limit)] + (with-retries + (fn retrieve-jwks-task [] + (if (str/starts-with? url "file://") + (-> url slurp json/read-str walk/keywordize-keys) + (let [correlation-id (utils/unique-identifier) + http-options (update http-options :headers assoc "x-cid" correlation-id)] + (log/info "updating jwks entries from server, cid is" correlation-id) + (pc/mapply hu/http-request http-client url http-options))))))) + +(defn refresh-keys-cache + "Update the cache of users with prestashed JWK keys." + [http-client http-options url supported-algorithms keys-cache] + (metrics/with-timer! + (metrics/waiter-timer "core" "jwt" "refresh-keys-cache") + (fn [elapsed-nanos] + (log/info "JWKS keys retrieval took" elapsed-nanos "ns")) + (let [response (retrieve-jwks-with-retries http-client url http-options)] + (when-not (map? response) + (throw (ex-info "Invalid response from the JWKS endpoint" + {:response response :url url}))) + (let [all-keys (:keys response) + keys (filter #(supported-key? supported-algorithms %) all-keys)] + (when (empty? keys) + (throw (ex-info "No supported keys found from the JWKS endpoint" + {:response response + :url url}))) + (log/info "retrieved entries from the JWKS endpoint" response) + (reset! keys-cache {:key-id->jwk (->> keys + (map attach-public-key) + (pc/map-from-vals :kid)) + :last-update-time (t/now) + :summary {:num-filtered-keys (count keys) + :num-jwks-keys (count all-keys)}}))))) + +(defn start-jwt-cache-maintainer + "Starts a timer task to maintain the keys-cache." + [http-client http-options jwks-url update-interval-ms supported-algorithms keys-cache] + {:cancel-fn (du/start-timer-task + (t/millis update-interval-ms) + (fn refresh-keys-cache-task [] + (refresh-keys-cache http-client http-options jwks-url supported-algorithms keys-cache))) + :query-state-fn (fn query-jwt-cache-state [] + @keys-cache)}) + +(def ^:const bearer-prefix "Bearer ") + +(def ^:const status-unauthorized 401) + +(def ^:const status-forbidden 403) + +(defn- validate-claims + "Checks the issuer in the `:iss` claim against one of the allowed issuers in the passed `:iss`. + Passed `:iss` must be a string. + If no `:iss` is passed, this check is not performed. + + Checks the `:aud` claim against the single valid audience in the passed `:aud`. + If no `:aud` is passed, this check is not performed. + + Checks the `:exp` claim is not less than now. + If no `:exp` claim exists, this check is not performed. + + Checks the `:sub` and subject-key claims are present. + + A check that fails raises an exception." + [{:keys [exp sub] :as claims} {:keys [aud iss subject-key]}] + ;; Check the `:iss` claim. + (when (and iss (not= (:iss claims) iss)) + (throw (ex-info (str "Issuer does not match " iss) + {:log-level :info + :status status-unauthorized}))) + ;; Check the `:aud` claim. + (when (and aud (not= aud (:aud claims))) + (throw (ex-info (str "Audience does not match " aud) + {:log-level :info + :status status-unauthorized}))) + ;; Check the `:exp` claim. + (when (nil? exp) + (throw (ex-info "No expiry provided in the token payload" + {:log-level :info + :status status-unauthorized}))) + (let [now-epoch (tc/to-epoch (t/now))] + (when (and (:exp claims) (<= (:exp claims) now-epoch)) + (throw (ex-info (format "Token is expired (%s)" (:exp claims)) + {:log-level :info + :status status-unauthorized})))) + ;; Check the `:sub` claim. + (when (str/blank? sub) + (throw (ex-info "No subject provided in the token payload" + {:log-level :info + :status status-unauthorized}))) + (when-not (= subject-key :sub) + (when (str/blank? (subject-key claims)) + (throw (ex-info (str "No " (name subject-key) " provided in the token payload") + {:log-level :info + :status status-unauthorized})))) + claims) + +(defn validate-access-token + "Validates the JWT access token using the provided keys, realm and issuer." + [token-type issuer subject-key supported-algorithms key-id->jwk realm request-scheme access-token] + (when (str/blank? realm) + (throw (ex-info "JWT authentication can only be used with host header" + {:log-level :info + :message "Host header is missing" + :status status-forbidden}))) + (when (not= :https request-scheme) + (throw (ex-info "JWT authentication can only be used with HTTPS connections" + {:log-level :info + :message "Must use HTTPS connection" + :status status-forbidden}))) + (when (str/blank? access-token) + (throw (ex-info "Must provide Bearer token in Authorization header" + {:log-level :info + :message "Access token is empty" + :status status-unauthorized}))) + (let [[jwt-header jwt-payload jwt-signature] (str/split access-token #"\." 3)] + (when (or (str/blank? jwt-header) + (str/blank? jwt-payload) + (str/blank? jwt-signature)) + (throw (ex-info "JWT access token is malformed" + {:log-level :info + :message "JWT access token is malformed" + :status status-unauthorized}))) + (let [{:keys [alg kid typ] :as decoded-header} (jwe/decode-header access-token)] + (log/info "access token header:" decoded-header) + (when (empty? decoded-header) + (throw (ex-info "JWT authentication must include header part" + {:log-level :info + :message "JWT header is missing" + :status status-forbidden}))) + (when-not (contains? supported-algorithms alg) + (throw (ex-info (str "Unsupported algorithm " alg " in token header, supported algorithms: " supported-algorithms) + {:log-level :info + :message "JWT header contains unsupported algorithm" + :status status-unauthorized}))) + (when (str/blank? kid) + (throw (ex-info "JWT header is missing key ID" + {:log-level :info + :message "JWT header is missing key ID" + :status status-unauthorized}))) + (when (not= typ token-type) + (throw (ex-info (str "Unsupported type " typ) + {:log-level :info + :message "JWT header contains unsupported type" + :status status-unauthorized}))) + (let [public-key (get-in key-id->jwk [kid ::public-key])] + (when (nil? public-key) + (throw (ex-info (str "No matching JWKS key found for key " kid) + {:key-id kid + :log-level :info + :message "No matching JWKS key found" + :status status-unauthorized}))) + (let [options {:alg alg + :aud realm + :iss issuer + :skip-validation true} + claims (try + (jwt/unsign access-token public-key options) + (catch ExceptionInfo ex + (let [data (assoc (ex-data ex) + :log-level :info + :status status-unauthorized)] + (throw (ex-info (.getMessage ex) data ex)))))] + (log/info "access token claims:" claims) + (validate-claims claims (assoc options :subject-key subject-key)) + claims))))) + +(defn current-time-secs + "Returns the current time in seconds." + [] + (-> (t/now) tc/to-long (/ 1000) long)) + +(defn request->realm + "Extracts the realm from the host header in the request." + [request] + (some-> request :headers (get "host") utils/authority->host)) + +(defn- access-token? + "Predicate to determine if an authorization header represents an access token." + [authorization] + (let [authorization (str authorization)] + (and (str/starts-with? authorization bearer-prefix) + (= 3 (count (str/split authorization #"\.")))))) + +(defn extract-claims + "Returns either claims in the access token provided in the request, or + an exception that occurred while attempting to extract the claims." + [token-type issuer subject-key supported-algorithms key-id->jwk request] + (let [validation-timer (metrics/waiter-timer "core" "jwt" "validation") + timer-context (timers/start validation-timer)] + (try + (let [realm (request->realm request) + request-scheme (utils/request->scheme request) + bearer-entry (auth/select-auth-header request access-token?) + access-token (str/trim (subs bearer-entry (count bearer-prefix))) + claims (validate-access-token token-type issuer subject-key supported-algorithms key-id->jwk realm + request-scheme access-token)] + (timers/stop timer-context) + (counters/inc! (metrics/waiter-counter "core" "jwt" "validation" "success")) + claims) + (catch Throwable throwable + (timers/stop timer-context) + (counters/inc! (metrics/waiter-counter "core" "jwt" "validation" "failed")) + (log/info throwable "error in access token validation") + throwable)))) + +(defn authenticate-request + "Performs authentication and then + - responds with an error response when authentication fails, or + - invokes the downstream request handler using the authenticated credentials in the request." + [request-handler token-type issuer subject-key supported-algorithms key-id->jwk password request] + (let [claims-or-throwable (extract-claims token-type issuer subject-key supported-algorithms key-id->jwk request)] + (if (instance? Throwable claims-or-throwable) + (if (-> claims-or-throwable ex-data :status (= status-unauthorized)) + ;; allow downstream processing before deciding on authentication challenge in response + (ru/update-response + (request-handler request) + (fn [{:keys [status] :as response}] + (if (and (= status status-unauthorized) (utils/waiter-generated-response? response)) + ;; add to challenge initiated by Waiter + (let [realm (request->realm request) + www-auth-header (if (str/blank? realm) + (str/trim bearer-prefix) + (str bearer-prefix "realm=\"" realm "\""))] + (log/debug "attaching www-authenticate header to response") + (ru/attach-header response "www-authenticate" www-auth-header)) + ;; non-401 response, avoid authentication challenge + response))) + ;; non-401 response avoids further downstream handler processing + (utils/exception->response claims-or-throwable request)) + (let [{:keys [exp] :as claims} claims-or-throwable + subject (subject-key claims) + auth-params-map (auth/auth-params-map :jwt subject) + auth-cookie-age-in-seconds (- exp (current-time-secs))] + (auth/handle-request-auth + request-handler request subject auth-params-map password auth-cookie-age-in-seconds))))) + +(defn wrap-auth-handler + "Wraps the request handler with a handler to trigger JWT access token authentication." + [{:keys [issuer keys-cache password subject-key supported-algorithms token-type]} request-handler] + (fn jwt-auth-handler [request] + (if (and (not (auth/request-authenticated? request)) + (auth/select-auth-header request access-token?)) + (authenticate-request request-handler token-type issuer subject-key supported-algorithms + (:key-id->jwk @keys-cache) password request) + (request-handler request)))) + +(defn retrieve-state + "Returns the state of the JWT authenticator." + [{:keys [keys-cache]}] + (let [cache-data (update @keys-cache :key-id->jwk + (fn stringify-public-keys [key-id->jwk] + (pc/map-vals #(update % ::public-key str) key-id->jwk)))] + {:cache-data cache-data})) + +(defrecord JwtAuthenticator [issuer keys-cache password subject-key supported-algorithms token-type]) + +(defn jwt-authenticator + "Factory function for creating jwt authenticator middleware" + [{:keys [http-options issuer jwks-url password subject-key supported-algorithms token-type update-interval-ms]}] + {:pre [(map? http-options) + (not (str/blank? issuer)) + (some? password) + (not (str/blank? jwks-url)) + (keyword? subject-key) + supported-algorithms + (set? supported-algorithms) + (empty? (set/difference supported-algorithms #{:eddsa :rs256})) + (not (str/blank? token-type)) + (and (integer? update-interval-ms) + (not (neg? update-interval-ms)))]} + (let [keys-cache (atom {}) + http-client (-> http-options + (utils/assoc-if-absent :client-name "waiter-jwt") + (utils/assoc-if-absent :user-agent "waiter-jwt") + hu/http-client-factory)] + (start-jwt-cache-maintainer http-client http-options jwks-url update-interval-ms supported-algorithms keys-cache) + (->JwtAuthenticator issuer keys-cache password subject-key supported-algorithms token-type))) diff --git a/waiter/src/waiter/auth/spnego.clj b/waiter/src/waiter/auth/spnego.clj index 0f955a193..276c7f406 100644 --- a/waiter/src/waiter/auth/spnego.clj +++ b/waiter/src/waiter/auth/spnego.clj @@ -25,30 +25,33 @@ [ring.util.response :as rr] [waiter.auth.authentication :as auth] [waiter.correlation-id :as cid] - [waiter.middleware :as middleware] [waiter.metrics :as metrics] [waiter.util.utils :as utils]) - (:import (org.ietf.jgss GSSManager GSSCredential GSSContext GSSException) - (java.util.concurrent ThreadPoolExecutor))) + (:import (java.util.concurrent ThreadPoolExecutor) + (org.ietf.jgss GSSManager GSSCredential GSSContext GSSException))) + +(def ^:const negotiate-prefix "Negotiate ") + +(defn- negotiate-token? + "Predicate to determine if an authorization header represents a spnego negotiate token." + [authorization] + (str/starts-with? (str authorization) negotiate-prefix)) (defn decode-input-token "Decode the input token from the negotiate line, expects the authorization token to exist" - ^bytes - [req] - (let [enc_tok (get-in req [:headers "authorization"]) - token-fields (str/split enc_tok #" ")] - (when (= "negotiate" (str/lower-case (first token-fields))) - (b64/decode (.getBytes ^String (last token-fields)))))) + ^bytes [request] + (when-let [negotiate-token (auth/select-auth-header request negotiate-token?)] + (some-> negotiate-token (str/split #" " 2) last str .getBytes b64/decode))) (defn encode-output-token "Take a token from a gss accept context call and encode it for use in a -authenticate header" [token] - (str "Negotiate " (String. ^bytes (b64/encode token)))) + (str negotiate-prefix (String. ^bytes (b64/encode token)))) (defn do-gss-auth-check - [^GSSContext gss_context req] + [^GSSContext gss-context req] (when-let [intok (decode-input-token req)] - (when-let [ntok (.acceptSecContext gss_context intok 0 (alength intok))] + (when-let [ntok (.acceptSecContext gss-context intok 0 (alength intok))] (encode-output-token ntok)))) (defn response-401-negotiate @@ -57,7 +60,7 @@ (log/info "triggering 401 negotiate for spnego authentication") (counters/inc! (metrics/waiter-counter "core" "response-status" "401")) (meters/mark! (metrics/waiter-meter "core" "response-status-rate" "401")) - (-> {:headers {"www-authenticate" "Negotiate"} + (-> {:headers {"www-authenticate" (str/trim negotiate-prefix)} :message "Unauthorized" :status 401} (utils/data->error-response request) @@ -134,13 +137,13 @@ returned instead. This middleware doesn't handle cookies for authentication, but that should be stacked before this handler." [request-handler ^ThreadPoolExecutor thread-pool-executor max-queue-length password] - (fn require-gss-handler [{:keys [headers] :as request}] + (fn require-gss-handler [request] (cond ;; Ensure we are not already queued with lots of Kerberos auth requests (too-many-pending-auth-requests? thread-pool-executor max-queue-length) (response-503-temporarily-unavailable request) ;; Try and authenticate using kerberos and add cookie in response when valid - (get-in request [:headers "authorization"]) + (auth/select-auth-header request negotiate-token?) (let [current-correlation-id (cid/get-correlation-id) gss-response-chan (async/promise-chan)] ;; launch task that will populate the response in response-chan diff --git a/waiter/src/waiter/core.clj b/waiter/src/waiter/core.clj index 37118bed1..a0a1667a2 100644 --- a/waiter/src/waiter/core.clj +++ b/waiter/src/waiter/core.clj @@ -33,6 +33,7 @@ [ring.util.response :as rr] [waiter.async-request :as async-req] [waiter.auth.authentication :as auth] + [waiter.auth.jwt :as jwt] [waiter.authorization :as authz] [waiter.cookie-support :as cookie-support] [waiter.correlation-id :as cid] @@ -108,6 +109,7 @@ ["/gc-services" :state-gc-for-services] ["/gc-transient-metrics" :state-gc-for-transient-metrics] ["/interstitial" :state-interstitial-handler-fn] + ["/jwt-authenticator" :state-jwt-authenticator-handler-fn] ["/kv-store" :state-kv-store-handler-fn] ["/launch-metrics" :state-launch-metrics-handler-fn] ["/leader" :state-leader-handler-fn] @@ -250,7 +252,7 @@ (cid/cinfo correlation-id "request trailers:" trailers-data) trailers-data))))) response (handler request) - add-headers (fn [{:keys [descriptor instance] :as response}] + add-headers (fn [{:keys [authorization/method authorization/principal authorization/user descriptor instance] :as response}] (let [{:strs [backend-proto]} (:service-description descriptor) backend-directory (:log-directory instance) backend-log-url (when backend-directory @@ -260,6 +262,9 @@ (update response :headers (fn [headers] (cond-> headers + method (assoc "x-waiter-auth-method" (name method)) + principal (assoc "x-waiter-auth-principal" (str principal)) + user (assoc "x-waiter-auth-user" (str user)) client-protocol (assoc "x-waiter-client-protocol" (name client-protocol)) internal-protocol (assoc "x-waiter-internal-protocol" (name internal-protocol)) request-time (assoc "x-waiter-request-date" request-date) @@ -610,6 +615,12 @@ :instance-rpc-chan (pc/fnk [] (async/chan 1024)) ; TODO move to service-chan-maintainer :interstitial-state-atom (pc/fnk [] (atom {:initialized? false :service-id->interstitial-promise {}})) + :jwt-authenticator (pc/fnk [[:settings authenticator-config] + passwords] + (let [jwt-config (:jwt authenticator-config)] + (when (not= :disabled jwt-config) + (let [jwt-config (assoc jwt-config :password (first passwords))] + (jwt/jwt-authenticator jwt-config))))) :local-usage-agent (pc/fnk [] (agent {})) :passwords (pc/fnk [[:settings password-store-config]] (let [password-provider (utils/create-component password-store-config) @@ -805,19 +816,22 @@ (fn async-trigger-terminate-fn [target-router-id service-id request-id] (async-req/async-trigger-terminate async-request-terminate-fn make-inter-router-requests-sync-fn router-id target-router-id service-id request-id))) - :authentication-method-wrapper-fn (pc/fnk [[:state authenticator passwords]] + :authentication-method-wrapper-fn (pc/fnk [[:state authenticator jwt-authenticator passwords]] (fn authentication-method-wrapper [request-handler] (let [auth-handler (auth/wrap-auth-handler authenticator request-handler) password (first passwords)] - (auth/wrap-auth-cookie-handler - password - (fn authenticate-request [request] - (cond - (:skip-authentication request) (do - (log/info "skipping authentication for request") - (request-handler request)) - (auth/request-authenticated? request) (request-handler request) - :else (auth-handler request))))))) + (cond->> (fn authenticate-request [request] + (cond + (:skip-authentication request) + (do + (log/info "skipping authentication for request") + (request-handler request)) + (auth/request-authenticated? request) + (request-handler request) + :else + (auth-handler request))) + jwt-authenticator (jwt/wrap-auth-handler jwt-authenticator) + true (auth/wrap-auth-cookie-handler password))))) :can-run-as?-fn (pc/fnk [[:state entitlement-manager]] (fn can-run-as [auth-user run-as-user] (authz/run-as? entitlement-manager auth-user run-as-user))) @@ -1497,6 +1511,14 @@ (wrap-secure-request-fn (fn state-interstitial-handler-fn [request] (handler/get-query-chan-state-handler router-id interstitial-query-chan request))))) + :state-jwt-authenticator-handler-fn (pc/fnk [[:state jwt-authenticator router-id] + wrap-secure-request-fn] + (let [query-state-fn (if (nil? jwt-authenticator) + (constantly :disabled) + #(jwt/retrieve-state jwt-authenticator))] + (wrap-secure-request-fn + (fn state-jwt-authenticator-handler-fn [request] + (handler/get-query-fn-state router-id query-state-fn request))))) :state-kv-store-handler-fn (pc/fnk [[:curator kv-store] [:state router-id] wrap-secure-request-fn] diff --git a/waiter/src/waiter/handler.clj b/waiter/src/waiter/handler.clj index 05b225233..c18e66930 100644 --- a/waiter/src/waiter/handler.clj +++ b/waiter/src/waiter/handler.clj @@ -621,8 +621,8 @@ (str (when scheme (str scheme "://")) host "/state/" path))] (utils/clj->streaming-json-response {:details (->> ["autoscaler" "autoscaling-multiplexer" "codahale-reporters" "fallback" "gc-broken-services" "gc-services" "gc-transient-metrics" "interstitial" - "kv-store" "launch-metrics" "leader" "local-usage" "maintainer" - "router-metrics" "scheduler" "service-description-builder" + "jwt-authenticator" "kv-store" "launch-metrics" "leader" "local-usage" + "maintainer" "router-metrics" "scheduler" "service-description-builder" "statsd"] (pc/map-from-keys make-url)) :router-id router-id diff --git a/waiter/src/waiter/schema.clj b/waiter/src/waiter/schema.clj index e11f18f56..096aa7579 100644 --- a/waiter/src/waiter/schema.clj +++ b/waiter/src/waiter/schema.clj @@ -99,3 +99,16 @@ [{:keys [kind] :as config}] (nil? (s/check {(s/required-key :factory-fn) s/Symbol, s/Keyword s/Any} (get config kind)))) +(def valid-jwt-authenticator-config + "Validator for the Zookeeper connection configuration. We allow either + a non-empty string (representing a connection string), or the keyword + :in-process, indicating that ZK should be started in-process" + (s/either + {(s/required-key :http-options) {s/Keyword s/Any} + (s/required-key :issuer) non-empty-string + (s/required-key :jwks-url) s/Str + (s/required-key :subject-key) s/Keyword + (s/required-key :supported-algorithms) #{s/Keyword} + (s/required-key :token-type) non-empty-string + (s/required-key :update-interval-ms) positive-int} + (s/constrained s/Keyword #(= :disabled %)))) diff --git a/waiter/src/waiter/settings.clj b/waiter/src/waiter/settings.clj index 4635e8f71..e8d085ff4 100644 --- a/waiter/src/waiter/settings.clj +++ b/waiter/src/waiter/settings.clj @@ -25,7 +25,8 @@ (def settings-schema {(s/required-key :authenticator-config) (s/constrained - {:kind s/Keyword + {(s/required-key :jwt) schema/valid-jwt-authenticator-config + :kind s/Keyword s/Keyword schema/require-symbol-factory-fn} schema/contains-kind-sub-map?) (s/required-key :blacklist-config) {(s/required-key :blacklist-backoff-base-time-ms) schema/positive-int @@ -246,7 +247,8 @@ utils/clj->json-response)) (def settings-defaults - {:authenticator-config {:kind :one-user + {:authenticator-config {:jwt :disabled + :kind :one-user :kerberos {:factory-fn 'waiter.auth.kerberos/kerberos-authenticator :concurrency-level 20 :keep-alive-mins 5 diff --git a/waiter/src/waiter/util/client_tools.clj b/waiter/src/waiter/util/client_tools.clj index ea4f76430..86847c5e8 100644 --- a/waiter/src/waiter/util/client_tools.clj +++ b/waiter/src/waiter/util/client_tools.clj @@ -564,6 +564,11 @@ [waiter-url & {:keys [cookies] :or {cookies {}}}] (retrieve-state-helper waiter-url "/state/interstitial" :cookies cookies)) +(defn jwt-authenticator-state + "Fetches and returns the interstitial state." + [waiter-url & {:keys [cookies] :or {cookies {}}}] + (retrieve-state-helper waiter-url "/state/jwt-authenticator" :cookies cookies)) + (defn kv-store-state "Fetches and returns the kv-store state." [waiter-url & {:keys [cookies] :or {cookies {}}}] @@ -1136,3 +1141,8 @@ (recur)))) (async/close! body-ch)) body-ch)) + +(defn jwt-auth-enabled? + "Returns true if JWT authentication is enabled." + [waiter-url] + (not= "disabled" (setting waiter-url [:authenticator-config :jwt]))) \ No newline at end of file diff --git a/waiter/src/waiter/util/ring_utils.clj b/waiter/src/waiter/util/ring_utils.clj index 227383dc3..5cc665389 100644 --- a/waiter/src/waiter/util/ring_utils.clj +++ b/waiter/src/waiter/util/ring_utils.clj @@ -47,3 +47,15 @@ (and status (>= status 400) (<= status 599))) + +(defn attach-header + "Attaches the specified header into the response." + [response header-name header-value] + (update-in + response + [:headers header-name] + (fn update-header-value [current-value] + (cond + (string? current-value) (str current-value "," header-value) + (seq? current-value) (conj current-value header-value) + :else header-value)))) diff --git a/waiter/test-files/jwt/jwks.json b/waiter/test-files/jwt/jwks.json new file mode 100644 index 000000000..a99904ec7 --- /dev/null +++ b/waiter/test-files/jwt/jwks.json @@ -0,0 +1,90 @@ +{ + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "kid": "7c368fc914ce6cb181fa0d670f63bd5df6db7b25", + "kty": "RSA", + "n": "vWmir2ZdXeMZkfsg0GTPfQw7CKmDNu50Sc76pndZPNyLf5JeR39JueHIPVXJ", + "use": "enc" + }, + { + "alg": "RS256", + "d": "Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97IjlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYTCBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLhBOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ", + "e": "AQAB", + "kid": "wwjcyqculjybrlzo0tzwjjniusfb4p4fakdotbf6", + "kty": "RSA", + "n": "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddxHmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMsD1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSHSXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ", + "use": "sig" + }, + { + "crv": "Ed25519", + "d": "8OSgWqVALOqZyLTLBcVJSgRZ6yxo0Wk_FkNG7uRCV28", + "kid": "f8f78b86-af1b-46c5-8849-006020f34d83", + "kty": "OKP", + "use": "sig", + "x": "H5UiUvUNr0OcxjEXpGCfFkYig-63Cs2SV_q6Y_5uDck" + }, + { + "crv": "Ed25519", + "d": "NIX7-o9XrTLAYNanapB9_fIeOnZUHug2jhwbZ6zs6dE", + "kid": "b66fd551-5e49-46e3-8c3b-06b953a16f6b", + "kty": "OKP", + "use": "sig", + "x": "gLYQSUzOu4T0xq12F_RCc51YgSc8w9GZK3WIyYRlH44" + }, + { + "crv": "Ed25519", + "d": "SXbPPwVHzPYYTwwRG_27RuoTN15Xn8zctwND727B2no", + "kid": "3106d31a-87a2-482f-a8dd-b727a043de5a", + "kty": "OKP", + "use": "sig", + "x": "qE4PwqfWP9pQ8iVpPhvQY433hiAn_rzjH-CPTnY_8yQ" + }, + { + "crv": "Ed25519", + "d": "dx_kwm1Kg47VwP-4DZHBO9bLdGgSu-w20VYwKyqHOw0", + "kid": "73711a87-eeb6-42be-b537-3fdeaa4872bb", + "kty": "OKP", + "use": "sig", + "x": "SycUT4WVy3AKTR_FxBjkAa468LocX8QrM480EK2EOoc" + }, + { + "crv": "Ed25519", + "d": "kdE8RVHsC87AdRSOA9jrJct75r-oZtggCgPQmpZHQzg", + "kid": "bfc58a16-8e07-42af-a632-2d3165dbf859", + "kty": "OKP", + "use": "sig", + "x": "nkKQj8rkxfvGFRcXNCeVz236ePxBZMtuxAp_F2PeIvk" + }, + { + "crv": "Ed25519", + "d": "oyi29vqepPO-ZCQLTnthB6-IWGME_88E3beFPwXK4Xs", + "kid": "e787c763-ffe1-405d-bdee-bc50ea0e3aa5", + "kty": "OKP", + "use": "sig", + "x": "nv4W45wfSuWBAKeY9hEJ_SLp1o3d0kdViCs9TpbsEWk" + }, + { + "crv": "Ed25519", + "d": "sH8QTNekLsfdXA29vipsLHiC8xQHqwteWcFtjM5gexs", + "kid": "9bf169b7-d8cc-4f60-82bd-2d060b11653b", + "kty": "OKP", + "use": "sig", + "x": "Tw32CCAlFNn2jz6wwVAQOgABuuA4pyLfaSnwzTEVhoY" + }, + { + "crv": "P-256", + "kid": "e787c763-ffe1-d8cc-b537-bc50ea0e3aa5", + "kty": "EC", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" + }, + { + "e": "AQAB", + "kid": "73711a87-eeb6-46e3-b537-2d060b11653b", + "kty": "RSA", + "n": "kWp2zRA23Z3vTL4uoe8kTFptxBVFunIoP4t_8TDYJrOb7D1iZNDXVeEsYKp6ppmrTZDAgd" + } + ] +} \ No newline at end of file diff --git a/waiter/test/waiter/auth/authenticator_test.clj b/waiter/test/waiter/auth/authenticator_test.clj index 4b6cd6201..efe739081 100644 --- a/waiter/test/waiter/auth/authenticator_test.clj +++ b/waiter/test/waiter/auth/authenticator_test.clj @@ -23,7 +23,8 @@ (deftest test-one-user-authenticator (let [username (System/getProperty "user.name") - authenticator (one-user-authenticator {:run-as-user username})] + authenticator (one-user-authenticator {:password [:cached "some-password"] + :run-as-user username})] (is (instance? SingleUserAuthenticator authenticator)) (let [request-handler (wrap-auth-handler authenticator identity) request {} diff --git a/waiter/test/waiter/auth/jwt_test.clj b/waiter/test/waiter/auth/jwt_test.clj new file mode 100644 index 000000000..eebc021f3 --- /dev/null +++ b/waiter/test/waiter/auth/jwt_test.clj @@ -0,0 +1,468 @@ +;; +;; Copyright (c) Two Sigma Open Source, LLC +;; +;; Licensed under the Apache License, Version 2.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. +;; +(ns waiter.auth.jwt-test + (:require [buddy.core.keys :as buddy-keys] + [buddy.sign.jwt :as jwt] + [clj-time.coerce :as tc] + [clj-time.core :as t] + [clojure.data.json :as json] + [clojure.test :refer :all] + [clojure.walk :as walk] + [plumbing.core :as pc] + [waiter.auth.authentication :as auth] + [waiter.auth.jwt :refer :all] + [waiter.test-helpers :refer :all] + [waiter.util.http-utils :as hu] + [waiter.util.utils :as utils]) + (:import (clojure.lang ExceptionInfo) + (java.security.interfaces RSAPublicKey) + (net.i2p.crypto.eddsa EdDSAPublicKey) + (waiter.auth.jwt JwtAuthenticator))) + +(deftest test-retrieve-public-key + (let [eddsa-entry (pc/keywordize-map + {"crv" "Ed25519", + "d" "0uecAYPVTD8_-0Xx3rWSr1EQZx6mB4_lPvXHrKtNp1M", + "kid" "dc918afa-d8cc-41cf-b537-8a40859d3f46", + "kty" "OKP", + "use" "sig", + "x" "3StIkhARy4UZMV0OkvB6ZrN3dAt9sh_X9ZI15n1yr-c"})] + (is (instance? EdDSAPublicKey (retrieve-public-key eddsa-entry)))) + (let [rs256-entry (pc/keywordize-map + {"alg" "RS256", + "d" "Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97IjlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYTCBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLhBOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ", + "e" "AQAB", + "kty" "RSA", + "n" "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddxHmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMsD1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSHSXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ", + "use" "sig"})] + (is (instance? RSAPublicKey (retrieve-public-key rs256-entry))))) + +(deftest test-refresh-keys-cache + (let [http-client (Object.) + keys-url "https://www.test.com/jwks/keys" + retry-limit 3 + http-options {:conn-timeout 10000 + :retry-interval-ms 10 + :retry-limit retry-limit + :socket-timeout 10000} + supported-algorithms #{:eddsa :rs256} + jwks-keys (walk/keywordize-keys + {"keys" [{"crv" "P-256", "kty" "OKP", "name" "fee", "use" "sig"} + {"crv" "Ed25519", "kty" "RSA", "name" "fie", "use" "sig", "x" "x1"} + {"crv" "Ed25519", "kid" "k1", "kty" "OKP", "name" "foe", "use" "sig", "x" "x2"} + {"crv" "Ed25519", "kty" "OKP", "name" "fum", "use" "enc", "x" "x3"} + {"e" "AQAB", "kid" "k2", "kty" "RSA", "name" "fum", "use" "sig", "n" "x1"} + {"crv" "Ed25519", "kty" "OKP", "name" "fum", "use" "sig", "x" "x4"}]}) + valid-key-entry (pc/map-vals + (fn [{:strs [kid] :as entry}] + (-> entry + (pc/keywordize-map) + (assoc :waiter.auth.jwt/public-key (str "public-key-" kid)))) + {"k1" {"crv" "Ed25519", "kid" "k1", "kty" "OKP", "name" "foe", "use" "sig", "x" "x2"} + "k2" {"e" "AQAB", "kid" "k2", "kty" "RSA", "name" "fum", "use" "sig", "n" "x1"}})] + + (testing "http-request throws exception always" + (let [http-request-counter (atom 0)] + (with-redefs [hu/http-request (fn [in-http-client in-url & _] + (is (= http-client in-http-client)) + (is (= keys-url in-url)) + (swap! http-request-counter inc) + (throw (IllegalStateException. "From test")))] + (let [keys-cache (atom {})] + (is (thrown-with-msg? IllegalStateException #"From test" + (refresh-keys-cache http-client http-options keys-url supported-algorithms keys-cache))) + (is (= {} @keys-cache)) + (is (= retry-limit @http-request-counter)))))) + + (testing "http-request throws exception once" + (let [current-time (t/now) + http-request-counter (atom 0)] + (with-redefs [hu/http-request (fn [in-http-client in-url & _] + (is (= http-client in-http-client)) + (is (= keys-url in-url)) + (swap! http-request-counter inc) + (if (= 1 @http-request-counter) + (throw (IllegalStateException. "From test")) + jwks-keys)) + t/now (constantly current-time) + retrieve-public-key (fn [{:keys [kid]}] (str "public-key-" kid))] + (let [keys-cache (atom {})] + (refresh-keys-cache http-client http-options keys-url supported-algorithms keys-cache) + (is (= {:key-id->jwk valid-key-entry + :last-update-time current-time + :summary {:num-filtered-keys 2 :num-jwks-keys 6}} + @keys-cache)) + (is (= 2 @http-request-counter)))))) + + (testing "http-request returns string" + (with-redefs [hu/http-request (fn [in-http-client in-url & _] + (is (= http-client in-http-client)) + (is (= keys-url in-url)) + "From test")] + (let [keys-cache (atom {})] + (is (thrown-with-msg? ExceptionInfo #"Invalid response from the JWKS endpoint" + (refresh-keys-cache http-client http-options keys-url supported-algorithms keys-cache))) + (is (= {} @keys-cache))))) + + (testing "http-request returns no keys" + (with-redefs [hu/http-request (fn [in-http-client in-url & _] + (is (= http-client in-http-client)) + (is (= keys-url in-url)) + {"keys" []})] + (let [keys-cache (atom {})] + (is (thrown-with-msg? ExceptionInfo #"No supported keys found from the JWKS endpoint" + (refresh-keys-cache http-client http-options keys-url supported-algorithms keys-cache))) + (is (= {} @keys-cache))))) + + (testing "http-request returns some keys" + (let [current-time (t/now)] + (with-redefs [hu/http-request (fn [in-http-client in-url & _] + (is (= http-client in-http-client)) + (is (= keys-url in-url)) + jwks-keys) + t/now (constantly current-time) + retrieve-public-key (fn [{:keys [kid]}] (str "public-key-" kid))] + (let [keys-cache (atom {})] + (refresh-keys-cache http-client http-options keys-url supported-algorithms keys-cache) + (is (= {:key-id->jwk valid-key-entry + :last-update-time current-time + :summary {:num-filtered-keys 2 :num-jwks-keys 6}} + @keys-cache)))))))) + +(deftest test-jwt-cache-maintainer + (let [jwks-data (-> "test-files/jwt/jwks.json" slurp json/read-str walk/keywordize-keys :keys) + data-counter (atom 1) + http-client (Object.) + keys-url "https://www.test.com/jwks/keys" + http-options {:conn-timeout 10000 + :socket-timeout 10000} + supported-algorithms #{:eddsa :rs256}] + (with-redefs [refresh-keys-cache (fn [in-http-client in-http-options in-url in-algorithms in-keys-cache] + (is (= http-client in-http-client)) + (is (= http-options in-http-options)) + (is (= keys-url in-url)) + (is (= supported-algorithms in-algorithms)) + (reset! in-keys-cache {:keys (take @data-counter jwks-data) + :last-update-time (t/now)}))] + (let [keys-cache (atom {}) + update-interval-ms 5 + {:keys [cancel-fn query-state-fn]} + (start-jwt-cache-maintainer + http-client http-options keys-url update-interval-ms supported-algorithms keys-cache)] + (try + (loop [counter 2] + (reset! data-counter counter) + (is (wait-for + (fn [] (= (take counter jwks-data) (:keys (query-state-fn)))) + :interval 5 :timeout 25 :unit-multiplier 1)) + (when (<= counter (count jwks-data)) + (recur (inc counter)))) + (finally + (cancel-fn))) + (is (= jwks-data (:keys @keys-cache))))))) + +(defn- generate-jwt-access-token + "Generates the JWT access token using the provided private key." + [alg jwk-entry payload header] + (let [private-key (buddy-keys/jwk->private-key (pc/keywordize-map jwk-entry)) + options {:alg alg :header header}] + (jwt/sign payload private-key options))) + +(deftest test-validate-access-token + (let [all-keys (-> "test-files/jwt/jwks.json" slurp json/read-str walk/keywordize-keys :keys) + issuer "test-issuer" + subject-key :sub + supported-algorithms #{:eddsa :rs256} + token-type "ty+pe" + realm "www.test-realm.com" + request-scheme :https + access-token "access-token"] + + (doseq [{:keys [alg jwks]} + [{:alg :eddsa + :jwks (->> (filter eddsa-key? all-keys) + (map attach-public-key) + (pc/map-from-vals :kid))} + {:alg :rs256 + :jwks (->> (filter rs256-key? all-keys) + (map attach-public-key) + (pc/map-from-vals :kid))}]] + (testing (str "algorithm " (name alg)) + (is (thrown-with-msg? ExceptionInfo #"JWT authentication can only be used with host header" + (validate-access-token token-type issuer subject-key supported-algorithms jwks nil request-scheme access-token))) + + (is (thrown-with-msg? ExceptionInfo #"JWT authentication can only be used with HTTPS connections" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm :http access-token))) + + (is (thrown-with-msg? ExceptionInfo #"Must provide Bearer token in Authorization header" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme " "))) + + (is (thrown-with-msg? ExceptionInfo #"JWT access token is malformed" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme "abcd"))) + + (let [jwk-entry (rand-nth (vals jwks)) + access-token (generate-jwt-access-token alg jwk-entry {} {})] + (is (thrown-with-msg? ExceptionInfo #"JWT header is missing key ID" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [jwk-entry (rand-nth (vals jwks)) + access-token (generate-jwt-access-token alg jwk-entry {} {:kid "invalid-key" :typ (str token-type ".err")})] + (is (thrown-with-msg? ExceptionInfo #"Unsupported type" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [jwk-entry (rand-nth (vals jwks)) + access-token (generate-jwt-access-token alg jwk-entry {} {:kid "invalid-key" :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"No matching JWKS key found for key invalid-key" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + access-token (generate-jwt-access-token alg jwk-entry {} {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"Issuer does not match test-issuer" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + access-token (generate-jwt-access-token alg jwk-entry {:iss issuer} {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"Audience does not match www.test-realm.com" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + expiry-time (+ (current-time-secs) 10000) + access-token (generate-jwt-access-token alg jwk-entry {:aud realm :exp expiry-time :iss issuer} {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"No subject provided in the token payload" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + payload {:aud realm :iss issuer :sub "foo@bar.com"} + access-token (generate-jwt-access-token alg jwk-entry payload {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"No expiry provided in the token payload" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + expiry-time (- (current-time-secs) 1000) + payload {:aud realm :exp expiry-time :iss issuer :sub "foo@bar.com"} + access-token (generate-jwt-access-token alg jwk-entry payload {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"Token is expired" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + expiry-time (+ (current-time-secs) 10000) + subject-key :custom-key + payload {:aud realm :exp expiry-time :iss issuer :sub "foo@bar.com"} + access-token (generate-jwt-access-token alg jwk-entry payload {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"No custom-key provided in the token payload" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + expiry-time (+ (current-time-secs) 10000) + payload {:aud realm :exp expiry-time :iss issuer :sub "foo@bar.com"} + access-token (generate-jwt-access-token alg jwk-entry payload {:kid kid :typ token-type})] + (is (= payload (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + expiry-time (+ (current-time-secs) 10000) + subject-key :custom-key + payload {:aud realm :custom-key "foo@bar.baz" :exp expiry-time :iss issuer} + access-token (generate-jwt-access-token alg jwk-entry payload {:kid kid :typ token-type})] + (is (thrown-with-msg? ExceptionInfo #"No subject provided in the token payload" + (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))) + + (let [{:keys [kid] :as jwk-entry} (rand-nth (vals jwks)) + expiry-time (+ (current-time-secs) 10000) + subject-key :custom-key + payload {:aud realm :custom-key "foo@bar.baz" :exp expiry-time :iss issuer :sub "foo@bar.com"} + access-token (generate-jwt-access-token alg jwk-entry payload {:kid kid :typ token-type})] + (is (= payload (validate-access-token token-type issuer subject-key supported-algorithms jwks realm request-scheme access-token)))))))) + +(deftest test-authenticate-request + (let [issuer "test-issuer" + keys (Object.) + subject-key :sub + supported-algorithms #{:eddsa :rs256} + token-type "jwt+type" + password "test-password"] + + (testing "error scenario - non 401" + (let [request-handler (fn [request] (assoc request :source ::request-handler)) + ex (ex-info (str "Test Exception " (rand-int 10000)) {}) + request {:headers {"authorization" "Bearer foo.bar.baz"} + :request-id (rand-int 10000)}] + (with-redefs [validate-access-token (fn [& _] (throw ex)) + utils/exception->response (fn [in-exception in-request] + (is (= (.getMessage ex) (.getMessage in-exception))) + (is (= request in-request)) + (assoc request :source ::exception-handler))] + (is (= (assoc request :source ::exception-handler) + (authenticate-request request-handler issuer subject-key supported-algorithms type keys password request)))))) + + (testing "error scenario 401 - downstream 200 from backend" + (let [request-handler (fn [request] (assoc request :source ::request-handler :status 200)) + ex (ex-info (str "Test Exception " (rand-int 10000)) {:status 401}) + request {:headers {"authorization" "Bearer foo.bar.baz"} + :request-id (rand-int 10000)}] + (with-redefs [validate-access-token (fn [& _] (throw ex))] + (is (= (assoc request :source ::request-handler :status 200) + (authenticate-request request-handler issuer subject-key supported-algorithms type keys password request)))))) + + (testing "error scenario 401 - downstream 200 from waiter" + (let [request-handler (fn [request] (-> request (dissoc :headers) (assoc :source ::request-handler :status 200) utils/attach-waiter-source)) + ex (ex-info (str "Test Exception " (rand-int 10000)) {:status 401}) + request {:headers {"authorization" "Bearer foo.bar.baz"} + :request-id (rand-int 10000)}] + (with-redefs [validate-access-token (fn [& _] (throw ex))] + (is (= (-> request + (dissoc :headers) + (assoc :source ::request-handler :status 200) + utils/attach-waiter-source) + (authenticate-request request-handler issuer subject-key supported-algorithms type keys password request)))))) + + (testing "error scenario 401 - downstream 401 from backend" + (let [request-handler (fn [request] (assoc request :source ::request-handler :status 401)) + ex (ex-info (str "Test Exception " (rand-int 10000)) {:status 401}) + request {:headers {"authorization" "Bearer foo.bar.baz"} + :request-id (rand-int 10000)}] + (with-redefs [validate-access-token (fn [& _] (throw ex))] + (is (= (assoc request :source ::request-handler :status 401) + (authenticate-request request-handler issuer subject-key supported-algorithms type keys password request)))))) + + (testing "error scenario 401 - downstream 401 from waiter" + (let [request-handler (fn [request] (-> request (dissoc :headers) (assoc :source ::request-handler :status 401) utils/attach-waiter-source)) + ex (ex-info (str "Test Exception " (rand-int 10000)) {:status 401}) + request {:headers {"authorization" "Bearer foo.bar.baz" + "host" "www.test.com"} + :request-id (rand-int 10000)} + auth-header (str bearer-prefix "realm=\"www.test.com\"")] + (with-redefs [validate-access-token (fn [& _] (throw ex))] + (is (= (-> request + (assoc :headers {"www-authenticate" auth-header} :source ::request-handler :status 401) + utils/attach-waiter-source) + (authenticate-request request-handler issuer subject-key supported-algorithms type keys password request)))))) + + (testing "success scenario - non 401" + (let [request-handler (fn [request] (assoc request :source ::request-handler)) + realm "www.test-realm.com" + request {:headers {"authorization" "Bearer foo.bar.baz" + "host" realm} + :request-id (rand-int 10000) + :scheme :test-scheme} + current-time (current-time-secs) + expiry-interval-secs 10000 + expiry-time (+ current-time expiry-interval-secs) + principal "foo@bar.com" + payload {:aud realm :exp expiry-time :iss issuer :sub principal}] + (with-redefs [validate-access-token (fn [in-type in-issuer in-sub-key in-algorithms in-keys in-realm in-request-scheme in-access-token] + (is (= token-type in-type)) + (is (= issuer in-issuer)) + (is (= subject-key in-sub-key)) + (is (= supported-algorithms in-algorithms)) + (is (= keys in-keys)) + (is (= realm in-realm)) + (is (= :test-scheme in-request-scheme)) + (is (= "foo.bar.baz" in-access-token)) + (is (= keys in-keys)) + payload) + auth/handle-request-auth (fn [request-handler request principal auth-params-map password auth-cookie-age-in-seconds] + (-> request + (assoc :auth-cookie-age-in-seconds auth-cookie-age-in-seconds + :auth-params-map auth-params-map + :password password + :principal principal) + request-handler)) + t/now (constantly (tc/from-long (* current-time 1000)))] + (is (= (assoc request + :auth-cookie-age-in-seconds expiry-interval-secs + :auth-params-map (auth/auth-params-map :jwt principal) + :password password + :principal principal + :source ::request-handler) + (authenticate-request request-handler token-type issuer subject-key supported-algorithms keys password request)))))))) + +(deftest test-jwt-authenticator + (with-redefs [start-jwt-cache-maintainer (constantly nil)] + (let [config {:http-options {:conn-timeout 10000 + :socket-timeout 10000} + :token-type "jwt+type" + :issuer "w8r" + :jwks-url "https://www.jwt-test.com/keys" + :password "test-password" + :subject-key :sub + :supported-algorithms #{:eddsa} + :update-interval-ms 1000}] + (testing "valid configuration" + (is (instance? JwtAuthenticator (jwt-authenticator config))) + (is (instance? JwtAuthenticator (jwt-authenticator (assoc config :supported-algorithms #{:eddsa :rs256}))))) + + (testing "invalid configuration" + (is (thrown? Throwable (jwt-authenticator (dissoc config :http-options)))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :token-type)))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :issuer)))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :jwks-url)))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :password)))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :subject-key)))) + (is (thrown? Throwable (jwt-authenticator (assoc config :subject-key "sub")))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :supported-algorithms)))) + (is (thrown? Throwable (jwt-authenticator (assoc config :supported-algorithms [:eddsa :rs256])))) + (is (thrown? Throwable (jwt-authenticator (assoc config :supported-algorithms #{:hs256})))) + (is (thrown? Throwable (jwt-authenticator (dissoc config :update-interval-ms)))))))) + +(deftest test-jwt-auth-handler + (let [handler (fn [{:keys [source]}] {:body source}) + supported-algorithms #{:eddsa} + keys-cache (atom {:key-id->jwk ::jwt-keys}) + authenticator (->JwtAuthenticator "issuer" keys-cache "password" :sub supported-algorithms "jwt+type") + jwt-handler (wrap-auth-handler authenticator handler)] + (with-redefs [authenticate-request (fn [handler token-type issuer subject-key in-algorithms keys password request] + (is (= "jwt+type" token-type)) + (is (= "issuer" issuer)) + (is (= :sub subject-key)) + (is (= supported-algorithms in-algorithms)) + (is (= ::jwt-keys keys)) + (is (= "password" password)) + (handler (assoc request :source ::jwt-auth)))] + (is (= {:body ::standard-request} + (jwt-handler {:headers {} + :source ::standard-request}))) + (is (= {:body ::standard-request} + (jwt-handler {:headers {"authorization" "Negotiate abcd"} + :source ::standard-request}))) + (is (= {:body ::standard-request} + (jwt-handler {:headers {"authorization" "Negotiate abcd,Negotiate wxyz"} + :source ::standard-request}))) + (is (= {:body ::standard-request} + (jwt-handler {:headers {"authorization" "Bearer abcdef"} + :source ::standard-request}))) + (is (= {:body ::standard-request} + (jwt-handler {:headers {"authorization" "Bearer abcdef,Bearer wxyz"} + :source ::standard-request}))) + (is (= {:body ::jwt-auth} + (jwt-handler {:headers {"authorization" "Bearer ab.cd.ef"} + :source ::standard-request}))) + (is (= {:body ::jwt-auth} + (jwt-handler {:headers {"authorization" "Bearer wxyz,Bearer ab.cd.ef"} + :source ::standard-request}))) + (is (= {:body ::jwt-auth} + (jwt-handler {:headers {"authorization" "Negotiate abcd,Bearer ab.cd.ef"} + :source ::standard-request}))) + (is (= {:body ::jwt-auth} + (jwt-handler {:headers {"authorization" "Bearer ab.cd.ef,Negotiate wxyz"} + :source ::standard-request}))) + (is (= {:body ::jwt-auth} + (jwt-handler {:headers {"authorization" "Negotiate abcd,Bearer ab.cd.ef,Negotiate wxyz"} + :source ::standard-request}))) + (is (= {:body ::standard-request} + (jwt-handler {:authorization/principal "user@test.com" + :authorization/user "user" + :headers {"authorization" "Bearer abcd"} + :source ::standard-request})))))) diff --git a/waiter/test/waiter/auth/spnego_test.clj b/waiter/test/waiter/auth/spnego_test.clj index 68b7c65ab..aee99ad40 100644 --- a/waiter/test/waiter/auth/spnego_test.clj +++ b/waiter/test/waiter/auth/spnego_test.clj @@ -60,7 +60,7 @@ (testing "kerberos authentication path" (with-redefs [too-many-pending-auth-requests? (constantly false)] - (let [auth-request (update standard-request :headers assoc "authorization" "foo-bar") + (let [auth-request (update standard-request :headers assoc "authorization" "Negotiate foo-bar") error-object (Object.)] (testing "401 response on failed authentication" @@ -73,6 +73,27 @@ (async/!! response-chan {:foo :bar}))] + (let [handler (require-gss request-handler thread-pool max-queue-length password) + response (handler standard-request) + response (if (map? response) + response + (async/!! response-chan {:foo :bar}))] + (let [handler (require-gss request-handler thread-pool max-queue-length password) + auth-request (update standard-request :headers assoc "authorization" "Bearer foo-bar") + response (handler auth-request) + response (if (map? response) + response + (async/!! response-chan {:error error-object}))] @@ -99,6 +120,24 @@ :headers {"www-authenticate" "test-token"}) (utils/dissoc-in response [:headers "set-cookie"])))))) + (testing "successful authentication - principal and token - multiple authorization header" + (with-redefs [populate-gss-credentials (fn [_ _ response-chan] + (async/>!! response-chan {:principal auth-principal + :token "test-token"}))] + (let [handler (require-gss request-handler thread-pool max-queue-length password) + auth-request (update standard-request :headers + assoc "authorization" "Bearer fee-fie,Negotiate foo-bar") + response (handler auth-request) + response (if (map? response) + response + (async/!! response-chan {:principal auth-principal}))] diff --git a/waiter/test/waiter/core_test.clj b/waiter/test/waiter/core_test.clj index ce1f77f66..f84919f77 100644 --- a/waiter/test/waiter/core_test.clj +++ b/waiter/test/waiter/core_test.clj @@ -23,6 +23,7 @@ [plumbing.core :as pc] [qbits.jet.client.http :as http] [waiter.auth.authentication :as auth] + [waiter.auth.jwt :as jwt] [waiter.authorization :as authz] [waiter.core :refer :all] [waiter.curator :as curator] @@ -1268,35 +1269,65 @@ (is (= handler-response response)))))) (deftest test-authentication-method-wrapper-fn - (let [standard-handler (fn [{:keys [authorization/principal]}] - {:principal principal - :source ::standard-handler})] - (let [authenticator (reify auth/Authenticator - (wrap-auth-handler [_ request-handler] - (is (= standard-handler request-handler)) - (fn [_] {:source ::auth-handler}))) - {:keys [authentication-method-wrapper-fn]} routines - authenticate-request-handler (authentication-method-wrapper-fn {:state {:authenticator authenticator - :passwords ["a" "b" "c"]}}) - request-handler (authenticate-request-handler standard-handler)] - - (testing "skip-authentication" - (is (= {:principal nil :source ::standard-handler} - (request-handler {:skip-authentication true, :headers {}})))) - - (testing "cookie-authentication" - (with-redefs [auth/get-and-decode-auth-cookie-value (constantly ["user@test.com" (System/currentTimeMillis)]) - auth/decoded-auth-valid? (fn [[principal _]] (some? principal))] - (is (= {:authorization/method :cookie, - :authorization/principal "user@test.com", - :authorization/user "user" - :principal "user@test.com" - :source ::standard-handler} - (request-handler {:headers {"cookie" "test-cookie"}}))))) - - (testing "require-authentication" - (is (= {:source ::auth-handler} (request-handler {}))) - (is (= {:source ::auth-handler} (request-handler {:headers {"cookie" "test-cookie"}}))))))) + (let [standard-handler (fn [request] (assoc request ::standard-handler true)) + jwt-authenticator (Object.)] + (with-redefs [jwt/wrap-auth-handler (fn [in-authenticator request-handler] + (is (= jwt-authenticator in-authenticator)) + (is (some? request-handler)) + (fn [request] + (-> request + (assoc ::jwt-authenticator + (-> request :headers (get "authorization") str (str/starts-with? "Bearer "))) + request-handler)))] + (let [authenticator (reify auth/Authenticator + (wrap-auth-handler [_ request-handler] + (is (= standard-handler request-handler)) + (fn [request] + (-> request + (assoc ::authenticator true) + request-handler)))) + {:keys [authentication-method-wrapper-fn]} routines + authenticate-request-handler (authentication-method-wrapper-fn {:state {:authenticator authenticator + :jwt-authenticator jwt-authenticator + :passwords ["a" "b" "c"]}}) + request-handler (authenticate-request-handler standard-handler)] + + (testing "skip-authentication" + (is (= {:headers {} + :skip-authentication true + ::jwt-authenticator false + ::standard-handler true} + (request-handler {:headers {} + :skip-authentication true})))) + + (testing "JWT authentication" + (is (= {:headers {"authorization" "Bearer abcd.efgh.ijkl"} + ::authenticator true + ::jwt-authenticator true + ::standard-handler true} + (request-handler {:headers {"authorization" "Bearer abcd.efgh.ijkl"}})))) + + (testing "cookie-authentication" + (with-redefs [auth/get-and-decode-auth-cookie-value (constantly ["user@test.com" (System/currentTimeMillis)]) + auth/decoded-auth-valid? (fn [[principal _]] (some? principal))] + (is (= {:headers {"cookie" "test-cookie"} + :authorization/method :cookie + :authorization/principal "user@test.com" + :authorization/user "user" + ::jwt-authenticator false + ::standard-handler true} + (request-handler {:headers {"cookie" "test-cookie"}}))))) + + (testing "require-authentication" + (is (= {::authenticator true + ::jwt-authenticator false + ::standard-handler true} + (request-handler {}))) + (is (= {:headers {"cookie" "test-cookie"} + ::authenticator true + ::jwt-authenticator false + ::standard-handler true} + (request-handler {:headers {"cookie" "test-cookie"}})))))))) (deftest test-waiter-request?-fn (testing "string hostname config" diff --git a/waiter/test/waiter/settings_test.clj b/waiter/test/waiter/settings_test.clj index 52ae07893..21e7c893d 100644 --- a/waiter/test/waiter/settings_test.clj +++ b/waiter/test/waiter/settings_test.clj @@ -79,6 +79,11 @@ [] (load-config-file "config-full.edn")) +(defn- load-k8s-settings + "Loads config-k8s.edn" + [] + (load-config-file "config-k8s.edn")) + (defn- load-min-settings "Loads config-minimal.edn" [] @@ -246,6 +251,23 @@ :z 4}}}} (deep-merge-settings defaults configured))))))) +(deftest test-validate-k8s-settings + (testing "Test validating k8s scheduler settings" + (let [graphite-server-port 5555 + port 12345 + run-as-user "foo"] + (with-redefs [env (fn [name _] + (case name + "GRAPHITE_SERVER_PORT" (str graphite-server-port) + "JWKS_SERVER_URL" "http://127.0.0.1:8040/jwks.json" + "WAITER_PORT" (str port) + "WAITER_AUTH_RUN_AS_USER" run-as-user + (throw (ex-info "Unexpected environment variable" {:name name}))))] + (let [settings (load-k8s-settings)] + (is (nil? (s/check settings-schema settings))) + (is (= port (:port settings))) + (is (= run-as-user (get-in settings [:authenticator-config :one-user :run-as-user])))))))) + (deftest test-validate-minimesos-settings (testing "Test validating minimesos settings" (let [graphite-server-port 5555 @@ -256,6 +278,7 @@ (with-redefs [env (fn [name _] (case name "GRAPHITE_SERVER_PORT" (str graphite-server-port) + "JWKS_SERVER_URL" "http://127.0.0.1:8040/jwks.json" "WAITER_PORT" (str port) "WAITER_AUTH_RUN_AS_USER" run-as-user "WAITER_MARATHON" marathon @@ -276,6 +299,7 @@ cluster-name "bar"] (with-redefs [env (fn [name _] (case name + "JWKS_SERVER_URL" "http://127.0.0.1:8040/jwks.json" "WAITER_PORT" (str port) "WAITER_AUTH_RUN_AS_USER" run-as-user "WAITER_CLUSTER_NAME" cluster-name @@ -296,6 +320,7 @@ (with-redefs [env (fn [name _] (case name "GRAPHITE_SERVER_PORT" (str graphite-server-port) + "JWKS_SERVER_URL" "http://127.0.0.1:8040/jwks.json" "SAML_IDP_CERT_URI" saml-idp-cert-uri "SAML_IDP_URI" saml-idp-uri "WAITER_AUTH_RUN_AS_USER" run-as-user