Skip to content
This repository has been archived by the owner on Mar 22, 2023. It is now read-only.

adds support for JWT access tokens #923

Merged
merged 22 commits into from Sep 7, 2019
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b9553af
adds implementation of jwt authenticator
shamsimam Aug 14, 2019
723d6ca
adds jwt authenticator settings
shamsimam Aug 16, 2019
395e6f4
adds integration test
shamsimam Aug 16, 2019
2884eee
addresses PR feedback
shamsimam Aug 21, 2019
051e3f5
addresses PR feedback
shamsimam Aug 21, 2019
6c91e19
triggers JWT authentication if auth header is deemed to be an access …
shamsimam Aug 22, 2019
b9da937
adds support for access tokens generated using rs256
shamsimam Aug 22, 2019
113133a
makes the supported algorithms configurable
shamsimam Aug 22, 2019
af02dde
passes control downstream even if JWT auth fails
shamsimam Aug 23, 2019
411c826
allows spnego auth to choose correct authorization header
shamsimam Aug 23, 2019
4141560
resolves the cheshire dependency used by buddy and jet
shamsimam Aug 24, 2019
e159b12
formatting changes
shamsimam Aug 29, 2019
79e0335
makes jet authentication disabled by default
shamsimam Aug 31, 2019
0e81345
addresses PR feedback
shamsimam Aug 31, 2019
171e3ca
introduces jwks server to enable jwt testing
shamsimam Sep 1, 2019
6eeed01
removes access-token creation from integration tests
shamsimam Sep 1, 2019
8045580
updates ci scripts for jwt tests
shamsimam Sep 1, 2019
a69c4eb
addresses PR feedback
shamsimam Sep 2, 2019
8ce48d2
avoids hacky use of private buddy.sign.jwt/validate-claims
shamsimam Sep 4, 2019
aaed111
adds integration tests to check the 401 unauthorized and 403 forbidde…
shamsimam Sep 4, 2019
f1f6116
corrects test - 403 responses do not content www-authenticate header
shamsimam Sep 4, 2019
b295e97
adds sources of the provided keys
shamsimam Sep 4, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 28 additions & 0 deletions 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/jwks.json
# 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}/test-files/jwt

echo "Starting JWKS server on port ${JWKS_PORT}"
( pushd ${JWT_DIR} && python3 -m http.server ${JWKS_PORT} && popd ) &

echo "Waiting for JWKS server..."
while ! curl -k http://127.0.0.1:${JWKS_PORT}/jwks.json &>/dev/null; do
echo -n .
sleep 3
done
echo
echo "JWKS server started successfully"
5 changes: 5 additions & 0 deletions waiter/bin/ci/run-integration-tests-composite-scheduler.sh
Expand Up @@ -43,6 +43,11 @@ 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}/jwks.json"

# Start waiter
${WAITER_DIR}/bin/run-using-composite-scheduler.sh ${WAITER_PORT} &

Expand Down
5 changes: 5 additions & 0 deletions waiter/bin/ci/run-integration-tests-k8s-scheduler.sh
Expand Up @@ -29,6 +29,11 @@ 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}/jwks.json"

# Start waiter
: ${WAITER_PORT:=9091}
${WAITER_DIR}/bin/run-using-k8s.sh ${WAITER_PORT} &
Expand Down
5 changes: 5 additions & 0 deletions waiter/bin/ci/run-integration-tests-marathon-scheduler.sh
Expand Up @@ -29,6 +29,11 @@ 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}/jwks.json"

# Start waiter
${WAITER_DIR}/bin/run-using-minimesos.sh ${WAITER_PORT} &
fi
Expand Down
4 changes: 3 additions & 1 deletion waiter/config-composite.edn
Expand Up @@ -34,7 +34,9 @@

; ---------- Security ----------

:authenticator-config {:kind :composite
:authenticator-config {:jwt {:issuer "test.com"
:jwks-url #config/env "JWKS_SERVER_URL"}
: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:
Expand Down
21 changes: 21 additions & 0 deletions waiter/config-full.edn
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion waiter/config-k8s.edn
Expand Up @@ -27,7 +27,9 @@

; ---------- Security ----------

:authenticator-config {:kind :one-user
:authenticator-config {:jwt {:issuer "test.com"
:jwks-url #config/env "JWKS_SERVER_URL"}
:kind :one-user
:one-user {;; The user account used to launch services:
:run-as-user #config/env "WAITER_AUTH_RUN_AS_USER"}}

Expand Down
7 changes: 5 additions & 2 deletions waiter/config-minimal.edn
Expand Up @@ -12,8 +12,11 @@

; ---------- Security ----------

:authenticator-config {:one-user {
;; the user account used to launch services
:authenticator-config {:jwt {;; acceptable issuer of the token
sradack marked this conversation as resolved.
Show resolved Hide resolved
:issuer "test.com"
;; url of the authorization server where the public keys (JWKS) are available:
:jwks-url "http://127.0.0.1:8040/jwks"}
:one-user {;; the user account used to launch services
:run-as-user "launch-username"}}

; ---------- Scheduling ----------
Expand Down
4 changes: 3 additions & 1 deletion waiter/config-minimesos.edn
Expand Up @@ -26,7 +26,9 @@

; ---------- Security ----------

:authenticator-config {:kind :one-user
:authenticator-config {:jwt {:issuer "test.com"
:jwks-url #config/env "JWKS_SERVER_URL"}
:kind :one-user
:one-user {;; The user account used to launch services:
:run-as-user #config/env "WAITER_AUTH_RUN_AS_USER"}}

Expand Down
4 changes: 3 additions & 1 deletion waiter/config-shell.edn
Expand Up @@ -17,7 +17,9 @@

; ---------- Security ----------

:authenticator-config {:kind :one-user
:authenticator-config {:jwt {:issuer "test.com"
:jwks-url "http://127.0.0.1:8040/jwks"}
:kind :one-user
:one-user {;; The user account used to launch services:
:run-as-user #config/env "WAITER_AUTH_RUN_AS_USER"}}

Expand Down
171 changes: 169 additions & 2 deletions waiter/integration/waiter/authentication_test.clj
Expand Up @@ -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
Expand Down Expand Up @@ -126,3 +128,168 @@
(finally
(delete-token-and-assert waiter-url token)))))))

(defn- retrieve-access-token
[waiter-url realm]
(if-let [access-token-url-env (System/getenv "INTEGRATION_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"))
(let [state-json (jwt-authenticator-state waiter-url)
sradack marked this conversation as resolved.
Show resolved Hide resolved
active-keys (-> state-json
(get-in ["state" "cache-data" "key-id->jwk"])
vals)
eddsa-keys (filter (fn [{:strs [crv]}] (= "Ed25519" crv)) active-keys)
{:strs [d kid] :as entry} (rand-nth eddsa-keys)
_ (when (string/blank? d)
(throw (ex-info "Private key not available from jwt authenticator state"
{:jwt-state state-json})))
{:keys [issuer subject-key token-type]} (setting waiter-url [:authenticator-config :jwt])
subject-key (keyword subject-key)
principal (retrieve-username)
expiry-time-secs (+ (long (/ (System/currentTimeMillis) 1000)) 120)
payload (cond-> {:aud realm :exp expiry-time-secs :iss issuer :sub principal}
(not= :sub subject-key) (assoc subject-key principal))
header {:kid kid :typ token-type}]
(generate-jwt-access-token :eddsa entry payload header))))

(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-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-url 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-bypass-jwt-authentication-waiter-realm
sradack marked this conversation as resolved.
Show resolved Hide resolved
(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-url 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 waiter-url 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-bypass-jwt-authentication-token-realm
sradack marked this conversation as resolved.
Show resolved Hide resolved
(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 waiter-url 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"))))
4 changes: 4 additions & 0 deletions waiter/project.clj
Expand Up @@ -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"]
Expand Down
14 changes: 12 additions & 2 deletions waiter/src/waiter/auth/authentication.clj
Expand Up @@ -123,6 +123,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.
Expand All @@ -135,12 +144,13 @@
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)))))
(handle-request-auth request-handler request :single-user run-as-user password))))

(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))
Expand Down