├── .clj-kondo ├── babashka │ └── fs │ │ └── config.edn ├── config.edn ├── etaoin │ └── etaoin │ │ ├── config.edn │ │ └── etaoin │ │ ├── api.clj_kondo │ │ ├── api2.clj_kondo │ │ └── hooks_util.clj_kondo └── potemkin │ └── potemkin │ ├── config.edn │ └── potemkin │ └── namespaces.clj ├── .dir-locals.el ├── .github ├── CODEOWNERS ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── linters.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── README.md ├── SECURITY.md ├── VERSION.txt ├── build.clj ├── check-for-reflection-warnings.sh ├── codecov.yml ├── deps.edn ├── docker-compose.yml ├── e2e └── saml20_clj │ └── e2e │ ├── entra.cert │ ├── keystore.jks │ ├── okta.cert │ ├── server.clj │ └── server_test.clj ├── keycloak └── realm.json ├── license └── LICENSE ├── src └── saml20_clj │ ├── coerce.clj │ ├── core.clj │ ├── crypto.clj │ ├── encode_decode.clj │ ├── sp │ ├── logout_response.clj │ ├── message.clj │ ├── metadata.clj │ ├── request.clj │ └── response.clj │ ├── specs.clj │ ├── state.clj │ └── xml.clj └── test └── saml20_clj ├── coerce_test.clj ├── crypto_test.clj ├── runners └── test.clj ├── sp ├── logout_response_test.clj ├── metadata_test.clj ├── request_test.clj └── response_test.clj ├── state_test.clj ├── test.clj ├── test ├── idp.cert ├── idp.private.key ├── keystore.jks ├── logout-response-authnfailure-with-signature.xml ├── logout-response-success-with-bad-signature.edn ├── logout-response-success-with-bad-signature.xml ├── logout-response-success-with-signature.edn ├── logout-response-success-with-signature.xml ├── logout-response-success-without-signature.edn ├── logout-response-success-without-signature.xml ├── metadata-with-keyinfo.xml ├── metadata-without-keyinfo.xml ├── response-invalid-confirmation-data.xml ├── response-no-issuer.xml ├── response-unsigned.xml ├── response-valid-confirmation-data.xml ├── response-with-encrypted-assertion.xml ├── response-with-signed-and-encrypted-assertion.xml ├── response-with-signed-and-encrypted-no-namespace-assertion.xml ├── response-with-signed-and-encrypted-saml2-assertion.xml ├── response-with-signed-assertion.xml ├── response-with-signed-message-and-assertion.xml ├── response-with-signed-message-and-encrypted-assertion.xml ├── response-with-signed-message-and-signed-and-encryped-assertion.xml ├── response-with-signed-message.xml ├── response-with-swapped-signature.xml ├── sp.cert └── sp.private.key └── xml_test.clj /.clj-kondo/babashka/fs/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {babashka.fs/with-temp-dir clojure.core/let}} 2 | -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:aliased-namespace-symbol {:level :warning} 3 | :case-symbol-test {:level :warning} 4 | :condition-always-true {:level :warning} 5 | :def-fn {:level :warning} 6 | :dynamic-var-not-earmuffed {:level :warning} 7 | :equals-expected-position {:level :warning, :only-in-test-assertion true} 8 | :keyword-binding {:level :warning} 9 | :main-without-gen-class {:level :warning} 10 | :minus-one {:level :warning} 11 | :misplaced-docstring {:level :warning} 12 | :missing-body-in-when {:level :warning} 13 | :missing-docstring {:level :warning} 14 | :missing-else-branch {:level :warning} 15 | :namespace-name-mismatch {:level :warning} 16 | :non-arg-vec-return-type-hint {:level :warning} 17 | :plus-one {:level :warning} 18 | :reduce-without-init {:level :warning} 19 | :redundant-call {:level :warning} 20 | :redundant-fn-wrapper {:level :warning} 21 | :self-requiring-namespace {:level :warning} 22 | :single-key-in {:level :warning} 23 | :unsorted-imports {:level :warning} 24 | :unsorted-required-namespaces {:level :warning} 25 | :unused-alias {:level :warning} 26 | :use {:level :warning} 27 | :warn-on-reflection {:level :warning}} 28 | 29 | :config-in-comment {:linters {:unresolved-symbol {:level :off}}} 30 | :lint-as {saml20-clj.sp.response/validate-confirmation-datas clojure.core/let}} 31 | -------------------------------------------------------------------------------- /.clj-kondo/etaoin/etaoin/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:etaoin/with-x-action {:level :error} 3 | :etaoin/binding-sym {:level :error} 4 | :etaoin/opts-map-type {:level :error} 5 | :etaoin/opts-map-pos {:level :error} 6 | :etaoin/empty-opts {:level :warning}} 7 | :hooks 8 | {:analyze-call 9 | {etaoin.api/with-chrome etaoin.api/with-browser 10 | etaoin.api/with-chrome-headless etaoin.api/with-browser 11 | etaoin.api/with-firefox etaoin.api/with-browser 12 | etaoin.api/with-firefox-headless etaoin.api/with-browser 13 | etaoin.api/with-edge etaoin.api/with-browser 14 | etaoin.api/with-edge-headless etaoin.api/with-browser 15 | etaoin.api/with-safari etaoin.api/with-browser 16 | 17 | etaoin.api/with-driver etaoin.api/with-driver 18 | etaoin.api/with-key-down etaoin.api/with-key-down 19 | etaoin.api/with-pointer-btn-down etaoin.api/with-pointer-btn-down 20 | 21 | ;; api2 moves to a more conventional let-ish vector syntax 22 | etaoin.api2/with-chrome etaoin.api2/with-browser 23 | etaoin.api2/with-chrome-headless etaoin.api2/with-browser 24 | etaoin.api2/with-edge etaoin.api2/with-browser 25 | etaoin.api2/with-edge-headless etaoin.api2/with-browser 26 | etaoin.api2/with-firefox etaoin.api2/with-browser 27 | etaoin.api2/with-firefox-headless etaoin.api2/with-browser 28 | etaoin.api2/with-safari etaoin.api2/with-browser}} 29 | :lint-as 30 | {etaoin.api/with-pointer-left-btn-down clojure.core/->}} 31 | -------------------------------------------------------------------------------- /.clj-kondo/etaoin/etaoin/etaoin/api.clj_kondo: -------------------------------------------------------------------------------- 1 | (ns etaoin.api 2 | (:require [clj-kondo.hooks-api :as api] 3 | [etaoin.hooks-util :as h])) 4 | 5 | (defn- nil-node? [n] 6 | (and (api/token-node? n) (nil? (api/sexpr n)))) 7 | 8 | (defn- with-bound-arg [node arg-offset] 9 | (let [macro-args (rest (:children node)) 10 | leading-args (take arg-offset macro-args) 11 | interesting-args (drop arg-offset macro-args) 12 | [opts binding-sym & body] (if (h/symbol-node? (second interesting-args)) 13 | interesting-args 14 | (cons nil interesting-args))] 15 | ;; if the user has specified nil or {} for options we can suggest that is not necessary 16 | (when (and opts 17 | (or (and (api/map-node? opts) (not (seq (:children opts)))) 18 | (nil-node? opts))) 19 | (api/reg-finding! (assoc (meta opts) 20 | :message "Empty or nil driver options can be omitted" 21 | :type :etaoin/empty-opts))) 22 | 23 | (cond 24 | (not (h/symbol-node? binding-sym)) 25 | ;; it makes more sense here to report on the incoming node position instead of what we expect to be the binding-sym 26 | (api/reg-finding! (assoc (meta node) 27 | :message "Expected binding symbol for driver" 28 | :type :etaoin/binding-sym)) 29 | 30 | ;; we don't want to explicitly expect a map because the map might come from 31 | ;; an evalution, but we can do some checks 32 | (and opts ;; we'll assume a list-node is a function call (eval) 33 | (not (nil-node? opts)) ;; nil is actually old-v1 syntax acceptable 34 | (not (api/list-node? opts)) ;; some fn call 35 | (not (h/symbol-node? opts)) ;; from a binding maybe 36 | ;; there are other eval node types... @(something) for example... maybe we'll add them in if folks ask 37 | (not (api/map-node? opts))) 38 | ;; we can report directly on the opts node, because at this point we know we expect 39 | ;; this arg position to be an opts map 40 | (api/reg-finding! (assoc (meta opts) 41 | :message "When specified, opts should be a map" 42 | :type :etaoin/opts-map-type)) 43 | 44 | ;; one last nicety, if the first form in body is a map, the user has accidentally swapped 45 | ;; binding and opt args 46 | (api/map-node? (first body)) 47 | (api/reg-finding! (assoc (meta (first body)) 48 | :message "When specified, opts must appear before binding symbol" 49 | :type :etaoin/opts-map-pos)) 50 | 51 | :else 52 | {:node (api/list-node 53 | (list* 54 | (api/token-node 'let) 55 | ;; simulate the effect, macro is creating a new thing (driver for example) 56 | ;; via binding it. I don't think the bound value matters for the linting process 57 | (api/vector-node [binding-sym (api/map-node [])]) 58 | ;; reference the other args so that they are not linted as unused 59 | (api/vector-node leading-args) 60 | opts ;; might be a binding, so ref it too 61 | body))}))) 62 | 63 | (defn- with-x-down 64 | "This is somewhat of a maybe an odd duck. 65 | I think it is assumed to be used within a threading macro. 66 | And itself employs a threadfirst macro. 67 | So each body form need to have an action (dummy or not) threaded into it." 68 | [node] 69 | (let [macro-args (rest (:children node)) 70 | [input x & body] macro-args 71 | dummy-action (api/map-node [])] 72 | {:node (api/list-node 73 | (apply list* 74 | (api/token-node 'do) 75 | ;; reference x and input just in case they contain something lint-relevant 76 | x input 77 | ;; dump the body, threading a dummy action in as first arg 78 | (map (fn [body-form] 79 | (cond 80 | ;; not certain this is absolutely what we want, but maybe close enough 81 | (h/symbol-node? body-form) (api/list-node (list* body-form dummy-action)) 82 | (api/list-node? body-form) (let [children (:children body-form)] 83 | (assoc body-form :children (apply list* 84 | (first children) 85 | dummy-action 86 | (rest children)))) 87 | :else 88 | (api/reg-finding! (assoc (meta body-form) 89 | :message "expected to be threaded through an action" 90 | :type :etaoin/with-x-action)))) 91 | body)))})) 92 | 93 | (defn with-browser 94 | "Covers etaoin.api/with-chrome and all its variants 95 | [opt? bind & body]" 96 | [{:keys [node]}] 97 | (with-bound-arg node 0)) 98 | 99 | (defn with-driver 100 | "Very similar to with-browser but bound arg is 1 deeper 101 | [type opt? bind & body]" 102 | [{:keys [node]}] 103 | (with-bound-arg node 1)) 104 | 105 | (defn with-key-down 106 | "[input key & body]" 107 | [{:keys [node]}] 108 | (with-x-down node)) 109 | 110 | (defn with-pointer-btn-down 111 | "[input button & body]" 112 | [{:keys [node]}] 113 | (with-x-down node)) 114 | -------------------------------------------------------------------------------- /.clj-kondo/etaoin/etaoin/etaoin/api2.clj_kondo: -------------------------------------------------------------------------------- 1 | (ns etaoin.api2 2 | (:require [clj-kondo.hooks-api :as api] 3 | [etaoin.hooks-util :as h])) 4 | 5 | (defn with-browser 6 | "Newer variants for api2 7 | [[bind & [options]] & body]" 8 | [{:keys [node]}] 9 | (let [macro-args (rest (:children node)) 10 | binding-like-vector (first macro-args)] 11 | (if-not (api/vector-node? binding-like-vector) 12 | ;; could use clj-kondo findings, but I think this is good for now 13 | (throw (ex-info "Expected vector for first arg" {})) 14 | (let [binding-sym (-> binding-like-vector :children first)] 15 | (if-not (h/symbol-node? binding-sym) 16 | (throw (ex-info "Expected binding symbol for first arg in vector" {})) 17 | (let [other-args (rest binding-like-vector) 18 | body (rest macro-args)] 19 | {:node (api/list-node 20 | (list* 21 | (api/token-node 'let) 22 | ;; simulate the effect, macro is creating a new thing (driver for example) 23 | ;; via binding it. I don't think the bound value matters for the linting process 24 | (api/vector-node [binding-sym (api/map-node [])]) 25 | ;; reference the other args so that they are not linted as unused 26 | (api/vector-node other-args) 27 | body))})))))) 28 | -------------------------------------------------------------------------------- /.clj-kondo/etaoin/etaoin/etaoin/hooks_util.clj_kondo: -------------------------------------------------------------------------------- 1 | (ns etaoin.hooks-util 2 | (:require [clj-kondo.hooks-api :as api])) 3 | 4 | (defn symbol-node? [node] 5 | (and (api/token-node? node) 6 | (symbol? (api/sexpr node)))) 7 | -------------------------------------------------------------------------------- /.clj-kondo/potemkin/potemkin/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {potemkin.collections/compile-if clojure.core/if 2 | potemkin.collections/reify-map-type clojure.core/reify 3 | potemkin.collections/def-map-type clj-kondo.lint-as/def-catch-all 4 | potemkin.collections/def-derived-map clj-kondo.lint-as/def-catch-all 5 | 6 | potemkin.types/reify+ clojure.core/reify 7 | potemkin.types/defprotocol+ clojure.core/defprotocol 8 | potemkin.types/deftype+ clojure.core/deftype 9 | potemkin.types/defrecord+ clojure.core/defrecord 10 | potemkin.types/definterface+ clojure.core/defprotocol 11 | potemkin.types/extend-protocol+ clojure.core/extend-protocol 12 | potemkin.types/def-abstract-type clj-kondo.lint-as/def-catch-all 13 | 14 | potemkin.utils/doit clojure.core/doseq 15 | potemkin.utils/doary clojure.core/doseq 16 | potemkin.utils/condp-case clojure.core/condp 17 | potemkin.utils/fast-bound-fn clojure.core/bound-fn 18 | 19 | potemkin.walk/prewalk clojure.walk/prewalk 20 | potemkin.walk/postwalk clojure.walk/postwalk 21 | potemkin.walk/walk clojure.walk/walk 22 | 23 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 24 | ;;;; top-level from import-vars 25 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 26 | 27 | ;; Have hooks 28 | ;;potemkin/import-fn potemkin.namespaces/import-fn 29 | ;;potemkin/import-macro potemkin.namespaces/import-macro 30 | ;;potemkin/import-def potemkin.namespaces/import-def 31 | 32 | ;; Internal, not transitive 33 | ;;potemkin/unify-gensyms potemkin.macros/unify-gensyms 34 | ;;potemkin/normalize-gensyms potemkin.macros/normalize-gensyms 35 | ;;potemkin/equivalent? potemkin.macros/equivalent? 36 | 37 | potemkin/condp-case clojure.core/condp 38 | potemkin/doit potemkin.utils/doit 39 | potemkin/doary potemkin.utils/doary 40 | 41 | potemkin/def-abstract-type clj-kondo.lint-as/def-catch-all 42 | potemkin/reify+ clojure.core/reify 43 | potemkin/defprotocol+ clojure.core/defprotocol 44 | potemkin/deftype+ clojure.core/deftype 45 | potemkin/defrecord+ clojure.core/defrecord 46 | potemkin/definterface+ clojure.core/defprotocol 47 | potemkin/extend-protocol+ clojure.core/extend-protocol 48 | 49 | potemkin/reify-map-type clojure.core/reify 50 | potemkin/def-derived-map clj-kondo.lint-as/def-catch-all 51 | potemkin/def-map-type clj-kondo.lint-as/def-catch-all} 52 | 53 | ;; leave import-vars alone, kondo special-cases it 54 | :hooks {:macroexpand {#_#_potemkin.namespaces/import-vars potemkin.namespaces/import-vars 55 | potemkin.namespaces/import-fn potemkin.namespaces/import-fn 56 | potemkin.namespaces/import-macro potemkin.namespaces/import-macro 57 | potemkin.namespaces/import-def potemkin.namespaces/import-def 58 | 59 | #_#_potemkin/import-vars potemkin.namespaces/import-vars 60 | potemkin/import-fn potemkin.namespaces/import-fn 61 | potemkin/import-macro potemkin.namespaces/import-macro 62 | potemkin/import-def potemkin.namespaces/import-def}}} 63 | -------------------------------------------------------------------------------- /.clj-kondo/potemkin/potemkin/potemkin/namespaces.clj: -------------------------------------------------------------------------------- 1 | (ns potemkin.namespaces 2 | (:require [clj-kondo.hooks-api :as api])) 3 | 4 | (defn import-macro* 5 | ([sym] 6 | `(def ~(-> sym name symbol) ~sym)) 7 | ([sym name] 8 | `(def ~name ~sym))) 9 | 10 | (defmacro import-fn 11 | ([sym] 12 | (import-macro* sym)) 13 | ([sym name] 14 | (import-macro* sym name))) 15 | 16 | (defmacro import-macro 17 | ([sym] 18 | (import-macro* sym)) 19 | ([sym name] 20 | (import-macro* sym name))) 21 | 22 | (defmacro import-def 23 | ([sym] 24 | (import-macro* sym)) 25 | ([sym name] 26 | (import-macro* sym name))) 27 | 28 | #_ 29 | (defmacro import-vars 30 | "Imports a list of vars from other namespaces." 31 | [& syms] 32 | (let [unravel (fn unravel [x] 33 | (if (sequential? x) 34 | (->> x 35 | rest 36 | (mapcat unravel) 37 | (map 38 | #(symbol 39 | (str (first x) 40 | (when-let [n (namespace %)] 41 | (str "." n))) 42 | (name %)))) 43 | [x])) 44 | syms (mapcat unravel syms) 45 | result `(do 46 | ~@(map 47 | (fn [sym] 48 | (let [vr (resolve sym) 49 | m (meta vr)] 50 | (cond 51 | (nil? vr) `(throw (ex-info (format "`%s` does not exist" '~sym) {})) 52 | (:macro m) `(def ~(-> sym name symbol) ~sym) 53 | (:arglists m) `(def ~(-> sym name symbol) ~sym) 54 | :else `(def ~(-> sym name symbol) ~sym)))) 55 | syms))] 56 | result)) 57 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((clojure-mode 2 | (cider-clojure-cli-aliases . "dev"))) 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | *.* @camsaul 2 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | inputs: 3 | java-version: 4 | required: true 5 | default: 21 6 | cache-key: 7 | required: true 8 | default: "deps" 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Prepare JDK 14 | uses: actions/setup-java@v4 15 | with: 16 | java-version: ${{ inputs.java-version }} 17 | distribution: temurin 18 | - name: Setup Clojure 19 | uses: DeLaGuardo/setup-clojure@9.5 20 | with: 21 | cli: 1.11.1.1208 22 | - name: Cache Dependencies 23 | uses: actions/cache@v4 24 | with: 25 | path: | 26 | ~/.m2 27 | ~/.gitlibs 28 | key: v1-${{ hashFiles('deps.edn') }}-${{ inputs.cache-key }} 29 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | Check: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | steps: 14 | - uses: actions/checkout@v4.1.7 15 | - uses: ./.github/actions/setup 16 | with: 17 | cache-key: "check" 18 | - run: clojure -M:check 19 | 20 | Eastwood: 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 10 23 | steps: 24 | - uses: actions/checkout@v4.1.7 25 | - uses: ./.github/actions/setup 26 | with: 27 | cache-key: "eastwood" 28 | - run: clojure -X:dev:eastwood 29 | 30 | Reflection-Warnings: 31 | runs-on: ubuntu-latest 32 | timeout-minutes: 10 33 | steps: 34 | - uses: actions/checkout@v4.1.7 35 | - uses: ./.github/actions/setup 36 | with: 37 | cache-key: "reflection-warnings" 38 | - run: ./check-for-reflection-warnings.sh 39 | 40 | Whitespace: 41 | runs-on: ubuntu-latest 42 | timeout-minutes: 10 43 | steps: 44 | - uses: actions/checkout@v4.1.7 45 | - uses: ./.github/actions/setup 46 | with: 47 | cache-key: "whitespace" 48 | - run: clojure -T:whitespace-linter lint 49 | 50 | Kondo: 51 | runs-on: ubuntu-latest 52 | timeout-minutes: 10 53 | steps: 54 | - uses: actions/checkout@v4.1.7 55 | - uses: ./.github/actions/setup 56 | with: 57 | cache-key: "kondo" 58 | - run: clojure -M:kondo 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'VERSION.txt' 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | environment: Deployment 14 | timeout-minutes: 10 15 | steps: 16 | - uses: actions/checkout@v4.1.0 17 | with: 18 | fetch-depth: 0 19 | - name: Prepare JDK 21 20 | uses: actions/setup-java@v4 21 | with: 22 | java-version: 21 23 | distribution: 'temurin' 24 | - name: Setup Clojure 25 | uses: DeLaGuardo/setup-clojure@12.1 26 | with: 27 | cli: 1.11.1.1413 28 | - name: Build saml20 29 | run: clojure -T:build build 30 | env: 31 | GITHUB_SHA: ${{ env.GITHUB_SHA }} 32 | - name: Deploy saml20 33 | run: clojure -T:build deploy 34 | env: 35 | CLOJARS_USERNAME: ${{ secrets.CLOJARS_USERNAME }} 36 | CLOJARS_PASSWORD: ${{ secrets.CLOJARS_PASSWORD }} 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | 11 | Test-Java-17: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | steps: 15 | - uses: actions/checkout@v4.1.7 16 | - uses: ./.github/actions/setup 17 | with: 18 | java-version: 17 19 | cache-key: "test" 20 | - run: clojure -X:dev:test 21 | 22 | Test-Java-21: 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 10 25 | steps: 26 | - uses: actions/checkout@v4.1.7 27 | - uses: ./.github/actions/setup 28 | with: 29 | java-version: 21 30 | cache-key: "test" 31 | - run: clojure -X:dev:test 32 | 33 | Test-Browser-e2e: 34 | runs-on: ubuntu-latest 35 | timeout-minutes: 10 36 | steps: 37 | - uses: actions/checkout@v4.1.7 38 | - uses: ./.github/actions/setup 39 | with: 40 | java-version: 21 41 | cache-key: "e2e" 42 | - run: docker compose up -d --wait 43 | - run: clojure -X:dev:e2e 44 | 45 | Cloverage: 46 | runs-on: ubuntu-latest 47 | timeout-minutes: 10 48 | steps: 49 | - uses: actions/checkout@v4.1.7 50 | - uses: ./.github/actions/setup 51 | with: 52 | java-version: 21 53 | cache-key: "cloverage" 54 | - run: clojure -X:dev:cloverage 55 | - name: Upload code coverage to codecov.io 56 | uses: codecov/codecov-action@v4 57 | with: 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | file: target/coverage/codecov.json 60 | flags: cloverage 61 | name: codecov-umbrella 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.jar 3 | .\#* 4 | .cpcache 5 | /*.iml 6 | /.clj-kondo/.cache 7 | /.eastwood 8 | /.env 9 | /.envrc 10 | /.idea 11 | /.lein-deps-sum 12 | /.lein-env 13 | /.lein-failures 14 | /.lein-plugins 15 | /.lein-repl-history 16 | /.lsp 17 | /.nrepl-port 18 | /build.xml 19 | /checkouts 20 | /classes 21 | /classes 22 | /config.edn 23 | /lib 24 | /pom.xml 25 | /pom.xml.asc 26 | /profiles.clj 27 | /resources/public/js/main.js 28 | /tags 29 | /target 30 | \#*\# 31 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is very important to us at Metabase. 4 | 5 | ## Supported Versions 6 | 7 | We typically only support the latest release of Metabase for maintenance updates, but depending on the nature of the security issue we may issue hotfixes for arbitrarily earlier versions of Metabase. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you discover any issue regarding security, please disclose the information responsibly by sending an email to security@metabase.com and not by creating a GitHub issue. We'll get back to you ASAP and work with you to confirm and plan a fix for the issue. 12 | 13 | Please note that we do not currently offer a bug bounty program. 14 | -------------------------------------------------------------------------------- /VERSION.txt: -------------------------------------------------------------------------------- 1 | 4.2.0 2 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.java.shell :as sh] 3 | [clojure.string :as str] 4 | [clojure.tools.build.api :as b] 5 | [deps-deploy.deps-deploy :as dd])) 6 | 7 | (def scm-url "git@github.com:metabase/saml20-clj.git") 8 | (def github-url "https://github.com/metabase/saml20-clj/") 9 | (def lib 'metabase/saml20-clj) 10 | 11 | (def version (str/trim (slurp "VERSION.txt"))) 12 | 13 | (def target "target") 14 | (def class-dir "target/classes") 15 | (def jar-file (format "target/%s-%s.jar" lib version)) 16 | 17 | 18 | (def sha 19 | (or (not-empty (System/getenv "GITHUB_SHA")) 20 | (not-empty (-> (sh/sh "git" "rev-parse" "HEAD") 21 | :out 22 | str/trim)))) 23 | 24 | (def pom-template 25 | [[:description "A library for delightful database interaction."] 26 | [:url github-url] 27 | [:licenses 28 | [:license 29 | [:name "Eclipse Public License"] 30 | [:url "http://www.eclipse.org/legal/epl-v10.html"]]] 31 | [:developers 32 | [:developer 33 | [:name "Cam Saul"]]] 34 | [:scm 35 | [:url github-url] 36 | [:connection (str "scm:git:" scm-url)] 37 | [:developerConnection (str "scm:git:" scm-url)] 38 | [:tag sha]]]) 39 | 40 | (def default-options 41 | {:lib lib 42 | :version version 43 | :jar-file jar-file 44 | :basis (b/create-basis {}) 45 | :class-dir class-dir 46 | :target target 47 | :src-dirs ["src"] 48 | :pom-data pom-template}) 49 | 50 | (defn build [opts] 51 | (let [opts (merge default-options opts)] 52 | (b/delete {:path target}) 53 | (println "\nWriting pom.xml...") 54 | (b/write-pom opts) 55 | (println "\nCopying source...") 56 | (b/copy-dir {:src-dirs ["src" "resources"] 57 | :target-dir class-dir}) 58 | (printf "\nBuilding %s...\n" jar-file) 59 | (b/jar opts) 60 | (println "Done."))) 61 | 62 | (defn install [opts] 63 | (printf "Installing %s to local Maven repository...\n" version) 64 | (b/install (merge default-options opts))) 65 | 66 | (defn build-and-install [opts] 67 | (build opts) 68 | (install opts)) 69 | 70 | (defn deploy [opts] 71 | (let [opts (merge default-options opts)] 72 | (printf "Deploying %s...\n" jar-file) 73 | (dd/deploy {:installer :remote 74 | :artifact (b/resolve-path jar-file) 75 | :pom-file (b/pom-path (select-keys opts [:lib :class-dir]))}) 76 | (println "Done."))) 77 | -------------------------------------------------------------------------------- /check-for-reflection-warnings.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | printf "\e[1;34mChecking for reflection warnings. This may take a few minutes, so sit tight...\e[0m\n" 4 | 5 | warnings=`clojure -M:check 2>&1 | grep Reflection | grep saml20 | sort | uniq` 6 | 7 | if [ ! -z "$warnings" ]; then 8 | printf "\e[1;31mYour code has introduced some reflection warnings.\e[0m 😞\n" 9 | echo "$warnings"; 10 | exit -1; 11 | fi 12 | 13 | printf "\e[1;32mNo reflection warnings! Success.\e[0m\n" 14 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | bot: "codecov-io" 3 | require_ci_to_pass: no 4 | 5 | coverage: 6 | status: 7 | project: 8 | default: 9 | # Project must always have at least this much coverage (by line) 10 | target: 65% 11 | # Whole-project test coverage is allowed to drop up to 1%. (For situtations where we delete code with full coverage) 12 | threshold: 1% 13 | patch: 14 | default: 15 | # Changes must have at least 75% test coverage (by line) 16 | target: 70% 17 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:mvn/repos 2 | {"opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}} 3 | 4 | :deps 5 | {org.clojure/spec.alpha {:mvn/version "0.5.238"} 6 | org.clojure/tools.logging {:mvn/version "1.3.0"} 7 | com.onelogin/java-saml {:mvn/version "2.9.0"} 8 | clojure.java-time/clojure.java-time {:mvn/version "1.4.2"} 9 | commons-io/commons-io {:mvn/version "2.16.1"} 10 | org.apache.santuario/xmlsec {:mvn/version "4.0.2"} ; use latest version and override transient dep from OpenSAML 11 | org.cryptacular/cryptacular {:mvn/version "1.2.7"} ; use latest version and override transient dep from OpenSAML 12 | org.opensaml/opensaml-core-api {:mvn/version "5.1.3"} 13 | org.opensaml/opensaml-core-impl {:mvn/version "5.1.3"} 14 | org.opensaml/opensaml-messaging-impl {:mvn/version "5.1.3"} 15 | org.opensaml/opensaml-saml-impl {:mvn/version "5.1.3"} 16 | org.opensaml/opensaml-xmlsec-api {:mvn/version "5.1.3"} 17 | org.opensaml/opensaml-xmlsec-impl {:mvn/version "5.1.3"} 18 | potemkin/potemkin {:mvn/version "0.4.7"} 19 | pretty/pretty {:mvn/version "1.0.5"} 20 | ring/ring-codec {:mvn/version "1.2.0"} 21 | jakarta.servlet/jakarta.servlet-api {:mvn/version "6.1.0"}} 22 | 23 | :aliases 24 | { 25 | :dev 26 | {:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1", :git/sha "dfb30dd6"} 27 | pjstadig/humane-test-output {:mvn/version "0.11.0"} 28 | org.clojure/tools.logging {:mvn/version "1.3.0"} 29 | org.apache.logging.log4j/log4j-core {:mvn/version "2.24.3"} 30 | org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.24.3"} 31 | ring/ring {:mvn/version "1.13.0"} 32 | etaoin/etaoin {:mvn/version "1.1.42"} 33 | ring/ring-jetty-adapter {:mvn/version "1.13.0"}} 34 | :extra-paths ["test" "e2e"]} 35 | 36 | ;; clojure -X:dev:test 37 | :test 38 | {:exec-fn saml20-clj.runners.test/test} 39 | 40 | ;; clojure -X:dev:e2e 41 | :e2e 42 | {:exec-fn saml20-clj.runners.test/test 43 | :jvm-opts ["-Dclojure.tools.logging.factory=clojure.tools.logging.impl/log4j2-factory"] 44 | :exec-args {:dirs ["e2e"]}} 45 | 46 | 47 | ;; clojure -M:check 48 | :check 49 | {:extra-deps {athos/clj-check {:git/url "https://github.com/athos/clj-check.git" 50 | :sha "d997df866b2a04b7ce7b17533093ee0a2e2cb729"}} 51 | :main-opts ["-m" "clj-check.check"]} 52 | 53 | ;; clojure -X:dev:eastwood 54 | :eastwood 55 | {:extra-deps {jonase/eastwood {:mvn/version "1.4.3"}} 56 | :exec-fn eastwood.lint/eastwood-from-cmdline 57 | :exec-args {:source-paths ["src"] 58 | :add-linters [:unused-fn-args 59 | :unused-locals] 60 | :exclude-linters [:deprecations 61 | :unused-ret-vals 62 | :implicit-dependencies]}} 63 | 64 | ;; clojure -X:dev:cloverage 65 | :cloverage 66 | {:extra-deps {cloverage/cloverage {:mvn/version "1.2.4"}} 67 | :exec-fn cloverage.coverage/run-project 68 | :exec-args {:fail-threshold 66 69 | :codecov? true 70 | ;; don't instrument logging forms, since they won't get executed as part of tests anyway 71 | ;; log calls expand to these 72 | :exclude-call [clojure.tools.logging/logf clojure.tools.logging/logp] 73 | :src-ns-path ["src"] 74 | :test-ns-path ["test"]}} 75 | 76 | ;; clojure -M:kondo 77 | :kondo 78 | {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2024.08.01"}} 79 | :main-opts ["-m" "clj-kondo.main" 80 | "--lint" "src"]} 81 | 82 | ;; clojure -T:whitespace-linter lint 83 | :whitespace-linter 84 | {:deps {com.github.camsaul/whitespace-linter {:sha "e35bc252ccf5cc74f7d543ef95ad8a3e5131f25b"}} 85 | :ns-default whitespace-linter 86 | :exec-args {:paths ["./.dir-locals.el" 87 | "./deps.edn" 88 | "src" 89 | "test"] 90 | :include-patterns ["\\.clj.?$" 91 | "\\.edn$" 92 | "\\.el$" 93 | "\\.xml$"]}} 94 | 95 | :include-license 96 | {:extra-paths ["license"]} 97 | 98 | ;; clojure -T:build build 99 | ;; clojure -T:build deploy 100 | :build 101 | {:deps {io.github.clojure/tools.build {:mvn/version "0.10.5"} 102 | slipset/deps-deploy {:mvn/version "0.2.2"}} 103 | :ns-default build}}} 104 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | keycloak: 3 | image: quay.io/keycloak/keycloak:latest 4 | command: ["start-dev", "--import-realm"] 5 | platform: linux/amd64 6 | environment: 7 | KC_LOG_LEVEL: INFO 8 | KC_REALM_NAME: test 9 | KC_BOOTSTRAP_ADMIN_USERNAME: admin 10 | KC_BOOTSTRAP_ADMIN_PASSWORD: thismustbethepassword 11 | ports: 12 | - 8080:8080 13 | volumes: 14 | - ./keycloak/:/opt/keycloak/data/import/:ro 15 | test-server: 16 | build: 17 | context: . 18 | dockerfile_inline: | 19 | FROM clojure:tools-deps 20 | COPY ./ /app 21 | platform: linux/amd64 22 | command: ["clj", "-M:dev:e2e", "-m", "saml20-clj.e2e.server"] 23 | working_dir: /app 24 | ports: 25 | - 3002:3002 26 | - 3001:3001 27 | volumes: 28 | - ./src:/app/src 29 | - ./test:/app/test 30 | - ./e2e:/app/e2e 31 | - ../java-opensaml/:/java-opensaml 32 | 33 | selenium: 34 | image: selenium/standalone-chrome:latest 35 | platform: linux/amd64 36 | ports: 37 | - 4444:4444 38 | - 7900:7900 39 | shm_size: '2gb' 40 | healthcheck-keycloak: 41 | restart: always 42 | image: curlimages/curl:latest 43 | entrypoint: ["/bin/sh", "-c", "--", "while true; do sleep 30; done;"] 44 | depends_on: 45 | - keycloak 46 | healthcheck: 47 | test: ["CMD", "curl", "-f", "http://keycloak:8080/"] 48 | interval: 3s 49 | timeout: 5s 50 | retries: 30 51 | healthcheck-test-server: 52 | restart: always 53 | image: curlimages/curl:latest 54 | entrypoint: ["/bin/sh", "-c", "--", "while true; do sleep 30; done;"] 55 | depends_on: 56 | - test-server 57 | healthcheck: 58 | test: ["CMD", "curl", "-f", "-k", "https://test-server:3001/"] 59 | interval: 3s 60 | timeout: 5s 61 | retries: 30 62 | healthcheck-selenium: 63 | restart: always 64 | image: curlimages/curl:latest 65 | entrypoint: ["/bin/sh", "-c", "--", "while true; do sleep 30; done;"] 66 | depends_on: 67 | - selenium 68 | healthcheck: 69 | test: ["CMD", "curl", "-f", "http://selenium:4444/"] 70 | interval: 3s 71 | timeout: 5s 72 | retries: 30 73 | -------------------------------------------------------------------------------- /e2e/saml20_clj/e2e/entra.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC8DCCAdigAwIBAgIQcvxenSvSG6BD3L5RwyvV6jANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQD 3 | EylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yNTAzMTIxNDUy 4 | NTVaFw0yODAzMTIxNDUyNTVaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQg 5 | U1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2crMOvNTAjYC 6 | 2LlbBRTBqJWbO+CW3IeiX5HHfgiQYiDcJSRV51junC/qrJ9gdUeJZ6vDivgbOA6z+a1yEK0gXQrV 7 | lZMBqb2OMFSzZOj+aI1jRRmuAzgkOUSL0C409oQpZCm62/Vg38cTAiyKiR6NLyDrAhtYllc+gOOM 8 | OnS1BhE/BzN6yMnR90csRMxfcX3MEUfgSz/RXalr06xrWS+uWpFE7I1bMrY/z3o23VLfDncesiIs 9 | jonEfAoGXl8A/WlVkNEe1J37tWZwVOocy0FfOgNtGgGlAA0TQe2vHkGfTmFPqRra1F/Dg/hR4lX5 10 | hGTao5NnhfkGNCQ1Ox/Pd8A6aQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAiUErRmsmKPvVHbns5 11 | jsjOtufsH69uZ8SF7YP8SIlteJdgtbVyMVi/mnQQfUXE/JY/+dbCeauzfNvaOUPDGzS64ghViAO9 12 | 5Rt01u9AfQkhzLpyOvQpMHfZkqI5M2yAg+AMMmawl08pWitZ4A00lwVOyThb29b+ohF6fA4ptueX 13 | ZMVvvlM6AktWBpPVyXTrmJ9A5TRHVr4aDNP4vQtO90IgpLBv/ql8I1R4bTAI2kO/QDY+XfZh7gQX 14 | uTakQzXFD9GtxBPwmLexXOzqMHKTvUujh/nclWuhHF+haaZv4isYEqgmcxeu8gTN3PXypCxYcOTV 15 | GBP7gtDKf+uhwHY+bhwQ 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /e2e/saml20_clj/e2e/keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabase/saml20-clj/72b45702afe821e0c9f7781b0575b06414f594e2/e2e/saml20_clj/e2e/keystore.jks -------------------------------------------------------------------------------- /e2e/saml20_clj/e2e/okta.cert: -------------------------------------------------------------------------------- 1 | MIIDqDCCApCgAwIBAgIGAZVIaTWrMA0GCSqGSIb3DQEBCwUAMIGUMQswCQYDVQQGEwJVUzETMBEG A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU MBIGA1UECwwLU1NPUHJvdmlkZXIxFTATBgNVBAMMDGRldi0wODU0ODIyNTEcMBoGCSqGSIb3DQEJ ARYNaW5mb0Bva3RhLmNvbTAeFw0yNTAyMjcxNzE1NDlaFw0zNTAyMjcxNzE2NDlaMIGUMQswCQYD VQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsG A1UECgwET2t0YTEUMBIGA1UECwwLU1NPUHJvdmlkZXIxFTATBgNVBAMMDGRldi0wODU0ODIyNTEc MBoGCSqGSIb3DQEJARYNaW5mb0Bva3RhLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC ggEBALOialzywPKChQZGU0LE4Og4e0kd4bCy3+QorhwrmRMGRhI4Vc91kDfBnEblH+R8Yqn28fMy sV9H7bwKls/CBljY/VwDUWLNupNPoRrmfOMwhe/X3wS3sLrq0cHw7Gpi8tKRgE9k6uXfNnSElj4+ by2wkgmLG+mb3S280SYZgfOKR+qtjDkdO+lxCpEHG8pHC7ayZhDsA8TgOeECI5Qia5v+Z+m+fMH3 RHUg7Zu51UKn2KN46T+dP9PdC34AoQ5oksUZ6Bu5+1eyzzFDgqHSJgOczb9JokGl6NnxoNkp9m8H fX15g9+UgVA+B5HsuPOS5WPOLZxYbiWJWV9MhgbZjxsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA NYYJ4NAnQUJmCMzZ6YGI2WWocHZcd5ivAo4TaHIs0UCCVXOtOHMJzCM4rHg/XUDzOWQZGApsJyJO 3h6m9LGJByJNXeuEZ1feP6pNePfE9ej3rf34GsiGVSk9T52atBQ0Uy4u491NE8j5FzB0fpK7zYFq 3BcrK7usaEozM1h+qVy3zMWnavuaxNFZNS/IF5Gbe61NtY1qvqG4JYb4XxymC0ljnr4R0KUQHVVu M1Fa+At4WLYieBE6urUAqpojCgIRWLCdtGFEImKRf0psmSxYCKBTL3kef+YI3dLQlkEo+PNNAx2t z61zG1baY1dckm3S7Bvbp1H4ljXZy1tZ0WgEKw== 2 | -------------------------------------------------------------------------------- /e2e/saml20_clj/e2e/server.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.e2e.server 2 | (:require 3 | [clojure.tools.logging :as logging] 4 | [ring.adapter.jetty :refer [run-jetty]] 5 | [ring.middleware.cookies :as ring.cookies] 6 | [ring.middleware.params :as ring.params] 7 | [ring.middleware.reload :as ring.reload] 8 | [ring.middleware.stacktrace :as ring.stacktrace] 9 | [ring.util.response :as ring.resp] 10 | [saml20-clj.sp.logout-response :as logout-response] 11 | [saml20-clj.sp.request :as request] 12 | [saml20-clj.sp.response :as response] 13 | [saml20-clj.test :as test])) 14 | 15 | (def home-page-logged-out 16 | (str "
" 17 | "" 22 | "")) 23 | (def home-page-logged-in 24 | (str "" 25 | "" 30 | "")) 31 | (def cookie-name "COOKIE") 32 | 33 | (defn idp-login-config 34 | [idp-type] 35 | (condp = idp-type 36 | :entra {:sp-name "SAMLTest" 37 | :acs-url "https://test-server:3001/login" 38 | :issuer "SAMLTest" 39 | :idp-url "https://login.microsoftonline.com/baac9aeb-12ed-4fe9-844f-fde9aa3fc2c7/saml2" 40 | :request-id "a-test-request" 41 | :protocol-binding :post 42 | :credential test/sp-private-key} 43 | :okta {:sp-name "SAMLTest" 44 | :acs-url "https://test-server:3001/login" 45 | :issuer "SAMLTest" 46 | :idp-url "https://dev-08548225.okta.com/app/dev-08548225_mbcitest_1/exknlfxer1RcyaTAS5d7/sso/saml" 47 | :request-id "a-test-request" 48 | :credential test/sp-private-key} 49 | :keycloak {:sp-name "SAMLTest" 50 | :acs-url "https://test-server:3001/login" 51 | :issuer "SAMLTest" 52 | :idp-url "http://keycloak:8080/realms/test/protocol/saml" 53 | :request-id "a-test-request" 54 | :credential test/sp-private-key})) 55 | 56 | (defn idp-logout-config 57 | [idp-type] 58 | (condp = idp-type 59 | :entra {:sp-name "SAMLTest" 60 | :acs-url "https://test-server:3001/logout" 61 | :issuer "SAMLTest" 62 | :idp-url "https://login.microsoftonline.com/baac9aeb-12ed-4fe9-844f-fde9aa3fc2c7/saml2" 63 | :relay-state "entra" 64 | :user-email "metatest@example.com" 65 | :request-id "a-test-request" 66 | :credential test/sp-private-key} 67 | :okta {:sp-name "SAMLTest" 68 | :acs-url "https://test-server:3001/logout" 69 | :issuer "SAMLTest" 70 | :idp-url "https://dev-08548225.okta.com/app/dev-08548225_mbcitest_1/exknlfxer1RcyaTAS5d7/slo/saml" 71 | :relay-state "okta" 72 | :user-email "metatest@example.com" 73 | :request-id "a-test-request" 74 | :credential test/sp-private-key} 75 | :keycloak {:sp-name "SAMLTest" 76 | :acs-url "https://test-server:3001/logout" 77 | :issuer "SAMLTest" 78 | :idp-url "http://keycloak:8080/realms/test/protocol/saml" 79 | :relay-state "keycloak" 80 | :user-email "metatest@example.com" 81 | :request-id "a-test-request" 82 | :credential test/sp-private-key})) 83 | 84 | (defn validation-config 85 | [idp-type] 86 | (condp = idp-type 87 | :entra {:idp-cert (slurp "e2e/saml20_clj/e2e/entra.cert") 88 | :acs-url "https://test-server:3001/login" 89 | :issuer "https://sts.windows.net/baac9aeb-12ed-4fe9-844f-fde9aa3fc2c7/" 90 | :request-id "a-test-request"} 91 | :okta {:idp-cert (slurp "e2e/saml20_clj/e2e/okta.cert") 92 | :acs-url "https://test-server:3001/login" 93 | :issuer "http://www.okta.com/exknlfxer1RcyaTAS5d7" 94 | :request-id "a-test-request"} 95 | :keycloak {:idp-cert test/idp-cert 96 | :acs-url "https://test-server:3001/login" 97 | :issuer "http://keycloak:8080/realms/test" 98 | :request-id "a-test-request"})) 99 | 100 | (defn serve-home 101 | [cookie] 102 | (let [body (if (get cookie cookie-name) 103 | home-page-logged-in 104 | home-page-logged-out)] 105 | (logging/debug "Serving Home") 106 | (-> {:status 200 107 | :body body} 108 | (ring.resp/content-type "text/html") 109 | (ring.resp/charset "utf-8")))) 110 | 111 | (defmulti handle-login (fn [method _] method)) 112 | (defmulti handle-logout (fn [method _] method)) 113 | 114 | (defmethod handle-logout :get [_ request] 115 | (if-let [idp-type (get-in request [:params "idp-type"])] 116 | (request/idp-logout-redirect-response (idp-logout-config (keyword idp-type))) 117 | (handle-logout :post request))) 118 | 119 | (defmethod handle-logout :post [_ request] 120 | (when (logout-response/validate-logout request 121 | (-> (get-in request [:params "RelayState"]) 122 | keyword 123 | validation-config)) 124 | (-> (ring.resp/redirect "/") 125 | (ring.resp/set-cookie cookie-name nil {:expires "Thu, 1 Jan 1970 00:00:00 GMT"})))) 126 | 127 | (defmethod handle-login :get [_ request] 128 | (if-let [idp-type (get-in request [:params "idp-type"])] 129 | (request/idp-redirect-response (assoc (idp-login-config (keyword idp-type)) :relay-state idp-type)) 130 | (handle-login :post request))) 131 | 132 | (defmethod handle-login :post [_ request] 133 | (when (response/validate-response request 134 | (-> (get-in request [:params "RelayState"]) 135 | keyword 136 | validation-config)) 137 | (-> (ring.resp/redirect "/") 138 | (ring.resp/set-cookie cookie-name "true")))) 139 | 140 | (defn handler 141 | [{:keys [uri cookies request-method] :as request}] 142 | (condp = uri 143 | "/" (serve-home cookies) 144 | "/login" (handle-login request-method request) 145 | "/logout" (handle-logout request-method request) 146 | {:status 404})) 147 | 148 | (defn start-server 149 | [] 150 | (logging/debug "Starting server") 151 | (run-jetty (-> handler 152 | ring.cookies/wrap-cookies 153 | ring.params/wrap-params 154 | ring.stacktrace/wrap-stacktrace 155 | (ring.reload/wrap-reload {:dirs ["src" "test" "e2e"]})) 156 | {:ssl? true 157 | :ssl-port 3001 158 | :port 3002 159 | :keystore "e2e/saml20_clj/e2e/keystore.jks" 160 | :key-password "testpassword"})) 161 | 162 | (defn -main 163 | [] 164 | (start-server)) 165 | -------------------------------------------------------------------------------- /e2e/saml20_clj/e2e/server_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.e2e.server-test 2 | (:require [clojure.test :as t] 3 | [etaoin.api :as etaoin])) 4 | 5 | (def ^:private test-overrides 6 | {:entra {:username "metatest@luizarakakimetabase.onmicrosoft.com" 7 | :password "ThisMustBeThePassword2!" }}) 8 | 9 | (t/deftest test-saml-login-logout 10 | (doseq [provider [:okta 11 | :keycloak]] 12 | (etaoin/with-chrome 13 | {:port 4444 14 | :host "localhost" 15 | :args ["--no-sandbox" 16 | "--ignore-ssl-errors=yes" 17 | "--ignore-certificate-errors"] 18 | :capabilities {"acceptInsecureCerts" true}} 19 | driver 20 | (t/testing "full saml login/logout flow" 21 | (etaoin/go driver "https://test-server:3001") 22 | (t/is (etaoin/visible? driver {:tag :a :id provider :fn/has-text "Login"})) 23 | (etaoin/click driver {:tag :a :id provider}) 24 | (etaoin/wait-visible driver {:tag :input :name :username}) 25 | (etaoin/fill driver {:tag :input :name :username} 26 | (get-in test-overrides [provider :username] "metatest@example.com")) 27 | (etaoin/fill driver {:tag :input :name :password} 28 | (get-in test-overrides [provider :password] "thismustbetheotherpassword")) 29 | (etaoin/click driver {:type :submit}) 30 | (etaoin/wait-visible driver {:tag :a :id provider}) 31 | (t/is (etaoin/visible? driver {:tag :a :id provider :fn/has-text "Logout"})) 32 | (etaoin/click driver {:tag :a :id provider}) 33 | (etaoin/wait-visible driver {:tag :a :fn/has-text "Login"}) 34 | (t/is (etaoin/visible? driver {:tag :a :id provider :fn/has-text "Login"})))))) 35 | 36 | 37 | (t/deftest test-saml-login-logout-entra 38 | (let [provider :entra] 39 | (etaoin/with-chrome 40 | {:port 4444 41 | :host "localhost" 42 | :args ["--no-sandbox" 43 | "--ignore-ssl-errors=yes" 44 | "--ignore-certificate-errors"] 45 | :capabilities {"acceptInsecureCerts" true}} 46 | driver 47 | (t/testing "full saml login/logout flow" 48 | (etaoin/go driver "https://test-server:3001") 49 | (t/is (etaoin/visible? driver {:tag :a :id provider :fn/has-text "Login"})) 50 | (etaoin/click driver {:tag :a :id provider}) 51 | (etaoin/wait-visible driver {:tag :input :name :loginfmt}) 52 | (etaoin/fill driver {:tag :input :name :loginfmt} 53 | (get-in test-overrides [provider :username] "metatest@example.com")) 54 | (etaoin/click driver {:type :submit}) 55 | (etaoin/wait-visible driver {:tag :input :name :passwd}) 56 | (etaoin/fill driver {:tag :input :name :passwd} 57 | (get-in test-overrides [provider :password] "thismustbetheotherpassword")) 58 | (etaoin/click driver {:type :submit}) 59 | (etaoin/wait-visible driver {:type :submit}) 60 | (etaoin/click driver {:type :submit}) 61 | (etaoin/wait-visible driver {:tag :a :id provider}) 62 | (t/is (etaoin/visible? driver {:tag :a :id provider :fn/has-text "Logout"})) 63 | (etaoin/click driver {:tag :a :id provider}) 64 | (etaoin/wait-visible driver {:tag :a :fn/has-text "Login"}) 65 | (t/is (etaoin/visible? driver {:tag :a :id provider :fn/has-text "Login"})))))) 66 | -------------------------------------------------------------------------------- /src/saml20_clj/core.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.core 2 | "Main interface for saml20-clj SP functionality. The core functionality is broken out into several separate 3 | namespaces, but vars are made available here via Potemkin." 4 | (:require [potemkin :as p] 5 | [saml20-clj.coerce :as coerce] 6 | [saml20-clj.crypto :as crypto] 7 | [saml20-clj.sp.logout-response :as logout-response] 8 | [saml20-clj.sp.metadata :as metadata] 9 | [saml20-clj.sp.request :as request] 10 | [saml20-clj.sp.response :as response] 11 | [saml20-clj.state :as state])) 12 | 13 | ;; this is so the linter doesn't complain about unused namespaces. 14 | (comment 15 | coerce/keep-me 16 | crypto/keep-me 17 | metadata/keep-me 18 | request/keep-me 19 | response/keep-me 20 | state/keep-me 21 | logout-response/keep-me) 22 | 23 | (p/import-vars 24 | [coerce 25 | ->X509Certificate 26 | ->Response 27 | ->xml-string] 28 | 29 | [crypto 30 | has-private-key?] 31 | 32 | [metadata 33 | metadata] 34 | 35 | [request 36 | idp-redirect-response 37 | logout-redirect-location 38 | idp-logout-redirect-response] 39 | 40 | [response 41 | decrypt-response 42 | assertions 43 | validate-response] 44 | 45 | [logout-response 46 | logout-success? 47 | validate-logout] 48 | 49 | [state 50 | record-request! 51 | accept-response! 52 | in-memory-state-manager]) 53 | -------------------------------------------------------------------------------- /src/saml20_clj/crypto.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.crypto 2 | (:require [saml20-clj.coerce :as coerce]) 3 | (:import [org.opensaml.saml.common.messaging.context SAMLPeerEntityContext SAMLProtocolContext] 4 | [org.opensaml.security.credential BasicCredential Credential] 5 | org.apache.xml.security.Init 6 | org.opensaml.messaging.context.MessageContext 7 | org.opensaml.saml.common.binding.security.impl.SAMLProtocolMessageXMLSignatureSecurityHandler 8 | org.opensaml.saml.common.xml.SAMLConstants 9 | org.opensaml.saml.saml2.binding.security.impl.SAML2HTTPRedirectDeflateSignatureSecurityHandler 10 | org.opensaml.saml.saml2.metadata.SPSSODescriptor 11 | org.opensaml.security.credential.impl.CollectionCredentialResolver 12 | org.opensaml.xmlsec.config.impl.DefaultSecurityConfigurationBootstrap 13 | org.opensaml.xmlsec.context.SecurityParametersContext 14 | org.opensaml.xmlsec.signature.support.impl.ExplicitKeySignatureTrustEngine 15 | org.opensaml.xmlsec.SignatureValidationParameters)) 16 | 17 | (set! *warn-on-reflection* true) 18 | 19 | (defn has-private-key? 20 | "Will check if the provided keystore contains a private key or not." 21 | [credential] 22 | (when-let [^Credential credential (try 23 | (coerce/->Credential credential) 24 | (catch Throwable _ 25 | (coerce/->Credential (coerce/->PrivateKey credential))))] 26 | (some? (.getPrivateKey credential)))) 27 | 28 | (defn- decrypt! [sp-private-key element] 29 | (when-let [sp-private-key (coerce/->PrivateKey sp-private-key)] 30 | (when-let [element (coerce/->Element element)] 31 | (com.onelogin.saml2.util.Util/decryptElement element sp-private-key)))) 32 | 33 | (defn recursive-decrypt! 34 | "Mutates a SAML object to decrypt any encrypted Assertions present." 35 | [sp-private-key element] 36 | (when-let [sp-private-key (coerce/->PrivateKey sp-private-key)] 37 | (when-let [element (coerce/->Element element)] 38 | (when (and (= (.getLocalName element) "EncryptedAssertion") 39 | (= (.getNamespaceURI element) "urn:oasis:names:tc:SAML:2.0:assertion")) 40 | (decrypt! sp-private-key element)) 41 | (doseq [i (range (.. element getChildNodes getLength)) 42 | ;; Explict typehinting here required by Cloverage 43 | :let [^org.w3c.dom.NodeList nodes (.getChildNodes element) 44 | child (.item nodes i)] 45 | :when (instance? org.w3c.dom.Element child)] 46 | (recursive-decrypt! sp-private-key child))))) 47 | 48 | (defonce ^:private -init 49 | (delay 50 | (Init/init) 51 | nil)) 52 | 53 | @-init 54 | 55 | (defn authenticated? 56 | "True if the MessageContext's PeerEntity subcontext has isAuthenticated set" 57 | [^MessageContext msg-ctx] 58 | (let [^SAMLPeerEntityContext peer-entity-ctx (.. msg-ctx 59 | (getSubcontext SAMLPeerEntityContext))] 60 | (.isAuthenticated peer-entity-ctx))) 61 | 62 | (defn- signature [object] 63 | (when-let [object (coerce/->SAMLObject object)] 64 | (.getSignature object))) 65 | 66 | (defn signed? 67 | "Returns true when an xml object has a top-level Signature Element" 68 | [object] 69 | (when-let [object (coerce/->SAMLObject object)] 70 | (.isSigned object))) 71 | 72 | (defn assert-signature-valid-when-present 73 | "Attempts to validate any signatures in a SAML object. Raises if signature validation fails." 74 | [object credential] 75 | (when-let [signature (signature object)] 76 | (when-let [credential (coerce/->Credential credential)] 77 | ;; validate that the signature conforms to the SAML signature spec 78 | (try 79 | (.validate (org.opensaml.saml.security.impl.SAMLSignatureProfileValidator.) signature) 80 | (catch Throwable e 81 | (throw (ex-info "Signature does not conform to SAML signature spec" 82 | {:object (coerce/->xml-string object)} 83 | e)))) 84 | ;; validate that the signature matches the credential 85 | (try 86 | (org.opensaml.xmlsec.signature.support.SignatureValidator/validate signature credential) 87 | (catch Throwable e 88 | (throw (ex-info "Signature does not match credential" 89 | {:object (coerce/->xml-string object)} 90 | e)))) 91 | :valid))) 92 | 93 | (defn- prepare-for-signature-validation 94 | ^MessageContext [^MessageContext msg-ctx issuer credential] 95 | (let [credential (doto ^BasicCredential (coerce/->Credential credential) 96 | (.setEntityId issuer)) 97 | sig-trust-engine (ExplicitKeySignatureTrustEngine. 98 | (CollectionCredentialResolver. [credential]) 99 | (DefaultSecurityConfigurationBootstrap/buildBasicInlineKeyInfoCredentialResolver)) 100 | sig-val-parameters (doto (SignatureValidationParameters.) 101 | (.setSignatureTrustEngine sig-trust-engine)) 102 | ^SAMLPeerEntityContext peer-entity-ctx (.ensureSubcontext msg-ctx SAMLPeerEntityContext) 103 | ^SAMLProtocolContext protocol-ctx (.ensureSubcontext msg-ctx SAMLProtocolContext) 104 | ^SecurityParametersContext sec-params-ctx (.ensureSubcontext msg-ctx SecurityParametersContext)] 105 | (doto peer-entity-ctx 106 | (.setEntityId issuer) 107 | (.setRole SPSSODescriptor/DEFAULT_ELEMENT_NAME)) 108 | (.setProtocol protocol-ctx SAMLConstants/SAML20P_NS) 109 | (.setSignatureValidationParameters sec-params-ctx sig-val-parameters) 110 | msg-ctx)) 111 | 112 | (defn handle-signature-security 113 | "Uses OpenSAMLs security handlers to verify the signature of an incoming request for both 114 | GET and POST-based SAML flows. 115 | 116 | Returns the verified MessageContext for the request. 117 | 118 | The SAMLPeerEntityContext subcontext of the MessageContext will have a method isAuthenticated 119 | that returns true if the signature verification succeeded. 120 | 121 | It will raise if the verification fails and a signature was provided. 122 | 123 | It will return the message context if no sigature was provided but isAuthenticated will be 124 | false." 125 | ^MessageContext [^MessageContext msg-ctx issuer credential & [request]] 126 | 127 | ;; if we have a GET request we are dealing with a redirect where the signature is the query parameters 128 | ;; this uses a different security handler than POST requests where the signature is embedded in the 129 | ;; XML Document 130 | (if (and request (= (:request-method request) :get)) 131 | (let [http-req-supplier (coerce/ring-request->HttpServletRequestSupplier request)] 132 | (doto (SAML2HTTPRedirectDeflateSignatureSecurityHandler.) 133 | (.setHttpServletRequestSupplier http-req-supplier) 134 | (.initialize) 135 | (.invoke (prepare-for-signature-validation msg-ctx issuer credential)))) 136 | (doto (SAMLProtocolMessageXMLSignatureSecurityHandler.) 137 | (.initialize) 138 | (.invoke (prepare-for-signature-validation msg-ctx issuer credential)))) 139 | 140 | msg-ctx) 141 | -------------------------------------------------------------------------------- /src/saml20_clj/encode_decode.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.encode-decode 2 | "Utility functions for encoding/decoding and compressing byte arrays and strings." 3 | (:require [clojure.string :as str]) 4 | (:import [org.apache.commons.codec.binary Base64])) 5 | 6 | (set! *warn-on-reflection* true) 7 | 8 | (defn str->bytes 9 | "Return a byte array from a String." 10 | ^bytes [^String some-string] 11 | (when some-string 12 | (.getBytes some-string "UTF-8"))) 13 | 14 | (defn- strip-ascii-armor 15 | ^String [^String s] 16 | (when s 17 | (-> s 18 | (str/replace #"-----BEGIN [A-Z\s]+-----" "") 19 | (str/replace #"-----END [A-Z\s]+-----" "") 20 | (str/replace #"[\n ]" "")))) 21 | 22 | (defn decode-base64 23 | "Return a decoded byte array from a base64 encoded byte array." 24 | ^bytes [^bytes bs] 25 | (when bs 26 | (Base64/decodeBase64 bs))) 27 | 28 | (defn base64-credential->bytes 29 | "Return a byte array from a base64 encoded security credential string." 30 | ^bytes [^String s] 31 | (when s 32 | (decode-base64 (str->bytes (strip-ascii-armor s))))) 33 | -------------------------------------------------------------------------------- /src/saml20_clj/sp/logout_response.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.logout-response 2 | "Handles parsing, validating and querying a LogoutResponse SAML message" 3 | (:require [saml20-clj.coerce :as coerce] 4 | [saml20-clj.sp.message :as message]) 5 | (:import [org.opensaml.saml.saml2.core LogoutResponse StatusCode] 6 | org.opensaml.saml.saml2.core.impl.LogoutRequestBuilder)) 7 | 8 | (set! *warn-on-reflection* true) 9 | 10 | (defn logout-success? 11 | "Return true if a LogoutResponse object has a SUCCESS SAML status element." 12 | [^LogoutResponse response] 13 | (let [status-value (.. response getStatus getStatusCode getValue)] 14 | (= status-value StatusCode/SUCCESS))) 15 | 16 | (def ^:private default-logout-validation-options 17 | {:response-validators [:signature 18 | :issuer 19 | :in-response-to 20 | :require-authenticated]}) 21 | 22 | (defn validate-logout 23 | "Decode a ring request into a LogoutResponse SAML object and validate it. 24 | 25 | Throws if validation fails" 26 | (^LogoutResponse [req request-id issuer idp-cert] 27 | (validate-logout req {:issuer issuer 28 | :idp-cert idp-cert 29 | :request-id request-id})) 30 | (^LogoutResponse [req options] 31 | (let [options (-> (merge default-logout-validation-options options) 32 | (assoc :request req :request-builder (LogoutRequestBuilder.))) 33 | {:keys [response-validators]} options] 34 | (when-let [msg-ctx (coerce/ring-request->MessageContext req)] 35 | (doseq [validator response-validators] 36 | (message/validate-message validator msg-ctx options)) 37 | (coerce/->LogoutResponse msg-ctx))))) 38 | -------------------------------------------------------------------------------- /src/saml20_clj/sp/message.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.message 2 | "Common validators for all SAML messages" 3 | (:require [saml20-clj.crypto :as crypto]) 4 | (:import [org.opensaml.messaging.context InOutOperationContext MessageContext] 5 | [org.opensaml.saml.saml2.core RequestAbstractType StatusResponseType Response] 6 | org.opensaml.messaging.handler.impl.CheckExpectedIssuer 7 | org.opensaml.saml.common.AbstractSAMLObjectBuilder 8 | org.opensaml.saml.common.binding.security.impl.InResponseToSecurityHandler)) 9 | 10 | (set! *warn-on-reflection* true) 11 | 12 | (defn- ->JavaFunction 13 | [func] 14 | (reify java.util.function.Function 15 | (apply [_ arg] 16 | (func arg)))) 17 | 18 | (defmulti validate-message 19 | "Peform a validation operation on a MessageCtx." 20 | (fn [validation _ _] 21 | (keyword validation))) 22 | 23 | (defmethod validate-message :signature 24 | [_ ^MessageContext msg-ctx {:keys [request issuer idp-cert] :or {issuer "unknown"}}] 25 | (assert (seq request) "Must provide original request") 26 | (assert (not (nil? idp-cert)) "Must provide a credential for the idp") 27 | (try 28 | (crypto/handle-signature-security msg-ctx issuer idp-cert request) 29 | (catch org.opensaml.messaging.handler.MessageHandlerException e 30 | (throw (ex-info "Message failed to validate signature" {:validator :signature} e))))) 31 | 32 | (defmethod validate-message :issuer 33 | [_ ^MessageContext msg-ctx {:keys [issuer]}] 34 | (assert (string? issuer) "Must provide issuer identifier for idp") 35 | (let [^StatusResponseType msg (.getMessage msg-ctx) 36 | incoming-issuer (when-let [issuer-element (.getIssuer msg)] 37 | (.getValue issuer-element))] 38 | (try 39 | (doto (CheckExpectedIssuer.) 40 | (.setExpectedIssuerLookupStrategy (->JavaFunction (constantly issuer))) 41 | (.setIssuerLookupStrategy (->JavaFunction (constantly incoming-issuer))) 42 | (.initialize) 43 | (.invoke msg-ctx)) 44 | (catch org.opensaml.messaging.handler.MessageHandlerException e 45 | (throw (ex-info "Message failed to validate issuer" 46 | {:validator :issuer 47 | :expected issuer 48 | :actual incoming-issuer} 49 | e)))))) 50 | 51 | ;; TODO: Replace this with usage of opensaml's client storage system 52 | (defmethod validate-message :in-response-to 53 | [_ ^MessageContext msg-ctx {:keys [request-id ^AbstractSAMLObjectBuilder request-builder]}] 54 | (assert (string? request-id) "Must provide the original request id") 55 | (assert (not (nil? request-builder)) "Must provide a request buidler") 56 | (let [^RequestAbstractType outgoing (.buildObject request-builder)] 57 | (.setID outgoing request-id) 58 | (InOutOperationContext. msg-ctx 59 | (doto (MessageContext.) 60 | (.setMessage outgoing)))) 61 | (try 62 | (doto (InResponseToSecurityHandler.) 63 | (.initialize) 64 | (.invoke msg-ctx)) 65 | (catch org.opensaml.messaging.handler.MessageHandlerException e 66 | (throw (ex-info "Message failed to validate InResponseTo" 67 | {:validator :in-response-to 68 | :original-request-id request-id 69 | :incoming-request-id (.getInResponseTo ^StatusResponseType (.getMessage msg-ctx))} 70 | e))))) 71 | 72 | (defn- maybe-get-assertions 73 | [response] 74 | (if (instance? Response response) 75 | (.getAssertions ^Response response) 76 | [])) 77 | 78 | (defmethod validate-message :require-authenticated 79 | ;; Requires the response be signed either in the query params (HTTP-Redirect) in the 80 | ;; XML body (HTTP-Post), must run after signature validation 81 | [_ ^MessageContext msg-ctx {:keys [decrypted-response]}] 82 | (when-not (crypto/authenticated? msg-ctx) 83 | (let [assertions (maybe-get-assertions decrypted-response)] 84 | (when (or (empty? assertions) 85 | (not (every? crypto/signed? assertions))) 86 | (throw (ex-info "Message is not Authenticated" 87 | {:is-authenticated (crypto/authenticated? msg-ctx) 88 | :validator :require-authenticated})))))) 89 | -------------------------------------------------------------------------------- /src/saml20_clj/sp/metadata.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.metadata 2 | (:require [clojure.string :as str] 3 | [saml20-clj.coerce :as coerce]) 4 | (:import [org.opensaml.saml.saml2.metadata.impl AssertionConsumerServiceBuilder EntityDescriptorBuilder KeyDescriptorBuilder NameIDFormatBuilder SingleLogoutServiceBuilder SPSSODescriptorBuilder] 5 | org.opensaml.core.xml.util.XMLObjectSupport 6 | org.opensaml.saml.common.xml.SAMLConstants 7 | org.opensaml.saml.saml2.core.NameIDType 8 | org.opensaml.security.credential.UsageType 9 | org.opensaml.xmlsec.keyinfo.impl.X509KeyInfoGeneratorFactory)) 10 | 11 | (set! *warn-on-reflection* true) 12 | 13 | (def ^:private name-id-formats 14 | [NameIDType/EMAIL NameIDType/TRANSIENT NameIDType/PERSISTENT NameIDType/UNSPECIFIED NameIDType/X509_SUBJECT]) 15 | 16 | (def ^:private cert-uses 17 | [UsageType/SIGNING UsageType/ENCRYPTION]) 18 | 19 | (defn metadata 20 | "Return string-encoded XML of this SAML SP's metadata." 21 | [{:keys [app-name acs-url slo-url sp-cert 22 | ^Boolean requests-signed 23 | ^Boolean want-assertions-signed] 24 | :or {want-assertions-signed true 25 | requests-signed true}}] 26 | (let [entity-descriptor (doto (.buildObject (EntityDescriptorBuilder.)) 27 | (.setID (str/replace acs-url #"[:/]" "_")) 28 | (.setEntityID app-name)) 29 | sp-sso-descriptor (doto (.buildObject (SPSSODescriptorBuilder.)) 30 | (.setAuthnRequestsSigned requests-signed) 31 | (.setWantAssertionsSigned want-assertions-signed) 32 | (.addSupportedProtocol SAMLConstants/SAML20P_NS))] 33 | 34 | (.. sp-sso-descriptor 35 | (getAssertionConsumerServices) 36 | (add (doto (.buildObject (AssertionConsumerServiceBuilder.)) 37 | (.setIndex (Integer. 0)) 38 | (.setIsDefault true) 39 | (.setLocation acs-url) 40 | (.setBinding SAMLConstants/SAML2_POST_BINDING_URI)))) 41 | (doseq [name-id-format name-id-formats] 42 | (.. sp-sso-descriptor 43 | (getNameIDFormats) 44 | (add (doto (.buildObject (NameIDFormatBuilder.)) 45 | (.setURI name-id-format))))) 46 | (when sp-cert 47 | (let [key-info-generator (.newInstance (doto (X509KeyInfoGeneratorFactory.) 48 | (.setEmitEntityCertificate true)))] 49 | (doseq [cert-use cert-uses] 50 | (.. sp-sso-descriptor 51 | (getKeyDescriptors) 52 | (add (doto (.buildObject (KeyDescriptorBuilder.)) 53 | (.setUse cert-use) 54 | (.setKeyInfo (.generate key-info-generator sp-cert)))))))) 55 | (when slo-url 56 | (.. sp-sso-descriptor 57 | (getSingleLogoutServices) 58 | (add (doto (.buildObject (SingleLogoutServiceBuilder.)) 59 | (.setBinding SAMLConstants/SAML2_POST_BINDING_URI) 60 | (.setLocation slo-url))))) 61 | 62 | (.. entity-descriptor 63 | (getRoleDescriptors) 64 | (add sp-sso-descriptor)) 65 | (coerce/->xml-string (XMLObjectSupport/marshall entity-descriptor)))) 66 | -------------------------------------------------------------------------------- /src/saml20_clj/sp/request.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.request 2 | (:require [clojure.string :as str] 3 | [java-time.api :as t] 4 | [saml20-clj.coerce :as coerce] 5 | [saml20-clj.state :as state]) 6 | (:import [org.opensaml.saml.common.messaging.context SAMLBindingContext SAMLEndpointContext SAMLPeerEntityContext] 7 | [org.opensaml.saml.saml2.core AuthnRequest LogoutRequest NameIDType] 8 | [org.opensaml.saml.saml2.core.impl AuthnRequestBuilder IssuerBuilder LogoutRequestBuilder NameIDBuilder NameIDPolicyBuilder] 9 | org.opensaml.messaging.context.MessageContext 10 | org.opensaml.saml.common.xml.SAMLConstants 11 | org.opensaml.saml.saml2.binding.encoding.impl.HTTPRedirectDeflateEncoder 12 | org.opensaml.saml.saml2.metadata.impl.SingleSignOnServiceBuilder 13 | org.opensaml.xmlsec.context.SecurityParametersContext 14 | org.opensaml.xmlsec.SignatureSigningParameters)) 15 | 16 | (set! *warn-on-reflection* true) 17 | 18 | (defn- non-blank-string? [s] 19 | (and (string? s) 20 | (not (str/blank? s)))) 21 | 22 | (defn- random-request-id 23 | "Generates a random ID for a SAML request, if none is provided." 24 | [] 25 | (str "id" (random-uuid))) 26 | 27 | (def ^:private -sig-alg "http://www.w3.org/2000/09/xmldsig#rsa-sha1") 28 | 29 | (defn- keyword->protocol-binding 30 | [binding-kw] 31 | (condp = binding-kw 32 | :post SAMLConstants/SAML2_POST_BINDING_URI 33 | :redirect SAMLConstants/SAML2_REDIRECT_BINDING_URI 34 | (throw (ex-info "Unsupported protocol binding argument" {:arg binding-kw 35 | :allowed [:redirect :post]})))) 36 | 37 | (defn- setup-message-context 38 | [message credential sig-alg idp-url] 39 | (let [msgctx (doto (MessageContext.) (.setMessage message))] 40 | (when credential 41 | (let [decoded-credential (try 42 | (coerce/->Credential credential) 43 | (catch Throwable _ 44 | (coerce/->Credential (coerce/->PrivateKey credential)))) 45 | ^SecurityParametersContext security-context (.getSubcontext msgctx SecurityParametersContext true)] 46 | (.setSignatureSigningParameters security-context 47 | (doto (SignatureSigningParameters.) 48 | (.setSignatureAlgorithm sig-alg) 49 | (.setSigningCredential decoded-credential))))) 50 | 51 | (let [^SAMLPeerEntityContext peer-context (.getSubcontext msgctx SAMLPeerEntityContext true) 52 | ^SAMLEndpointContext endpoint-context (.getSubcontext peer-context SAMLEndpointContext true)] 53 | (.setEndpoint endpoint-context 54 | (doto (.buildObject (SingleSignOnServiceBuilder.)) 55 | (.setBinding SAMLConstants/SAML2_REDIRECT_BINDING_URI) 56 | (.setLocation idp-url)))) 57 | msgctx)) 58 | 59 | (defn- build-authn-obj 60 | ^AuthnRequest [request-id instant sp-name idp-url acs-url issuer protocol-binding] 61 | (doto (.buildObject (AuthnRequestBuilder.)) 62 | (.setID request-id) 63 | (.setIssueInstant instant) 64 | (.setDestination idp-url) 65 | (.setProtocolBinding (keyword->protocol-binding protocol-binding)) 66 | (.setIsPassive false) 67 | (.setProviderName sp-name) 68 | (.setAssertionConsumerServiceURL acs-url) 69 | (.setNameIDPolicy (doto (.buildObject (NameIDPolicyBuilder.)) 70 | (.setFormat NameIDType/UNSPECIFIED))) 71 | (.setIssuer (doto (.buildObject (IssuerBuilder.)) 72 | (.setValue issuer))))) 73 | 74 | (defn- authn-request 75 | "Return an OpenSAML MessageContext Object with a SAML AuthnRequest." 76 | ^MessageContext [request-id 77 | sp-name 78 | acs-url 79 | idp-url 80 | issuer 81 | state-manager 82 | credential 83 | sig-alg 84 | instant 85 | protocol-binding] 86 | (let [request (build-authn-obj request-id instant sp-name idp-url acs-url issuer protocol-binding)] 87 | (when state-manager 88 | (state/record-request! state-manager (.getID request))) 89 | (setup-message-context request credential sig-alg idp-url))) 90 | 91 | (defn- map-making-servlet 92 | "Implements a minimum HttpServletResponse for HTTPRedirectDeflateEncoder" 93 | [] 94 | (let [response (atom {:status 302 :body "" :headers {}}) 95 | servlet-wrapper (reify jakarta.servlet.http.HttpServletResponse 96 | (setHeader [_this name value] 97 | (swap! response update :headers assoc name value)) 98 | (^void setCharacterEncoding [_ ^String _]) 99 | (sendRedirect [this redirect] 100 | (.setHeader this "location" redirect))) 101 | wrapper-supplier (reify net.shibboleth.shared.primitive.NonnullSupplier 102 | (get [_] servlet-wrapper))] 103 | [wrapper-supplier #(deref response)])) 104 | 105 | (defn- redirect-response 106 | [^MessageContext saml-request relay-state] 107 | (let [[servlet ->ring-request] (map-making-servlet) 108 | ^SAMLBindingContext binding-context (.getSubcontext saml-request SAMLBindingContext true)] 109 | ;; set the relay state 110 | (.setRelayState binding-context relay-state) 111 | 112 | ;; Hand over to an opensaml encoder with a servletresponse implementation that allows us to 113 | ;; retrieve the result as a ring map 114 | (doto (HTTPRedirectDeflateEncoder.) 115 | (.setMessageContext saml-request) 116 | (.setHttpServletResponseSupplier servlet) 117 | (.initialize) 118 | (.encode)) 119 | (->ring-request))) 120 | 121 | (defn- build-logout-obj 122 | ^LogoutRequest [issuer user-email idp-url instant request-id] 123 | (assert (non-blank-string? idp-url) "idp-url is required") 124 | (assert (non-blank-string? issuer) "issuer is required") 125 | (assert (non-blank-string? user-email) "user-email is required") 126 | (doto (.buildObject (LogoutRequestBuilder.)) 127 | (.setID request-id) 128 | (.setIssueInstant instant) 129 | (.setDestination idp-url) 130 | (.setIssuer (doto (.buildObject (IssuerBuilder.)) 131 | (.setValue issuer))) 132 | (.setNameID (doto (.buildObject (NameIDBuilder.)) 133 | (.setValue user-email))))) 134 | 135 | (defn idp-redirect-response 136 | "Return Ring response for HTTP 302 redirect." 137 | [{:keys [;; e.g. something like a UUID. Random UUID will be used if no other ID is provided 138 | request-id 139 | ;; e.g. "Metabase" 140 | sp-name 141 | ;; e.g. http://sp.example.com/demo1/index.php?acs 142 | acs-url 143 | ;; e.g. http://idp.example.com/SSOService.php 144 | idp-url 145 | ;; e.g. http://sp.example.com/demo1/metadata.php 146 | issuer 147 | ;; If present, record the request 148 | state-manager 149 | ;; If present, we can sign the request 150 | credential 151 | ;; Signature Algorithm 152 | sig-alg 153 | ;; relay-state argument that will be returned by the provider 154 | relay-state 155 | ;; protocol binding specifying if IdP should use HTTP-Post or HTTP-Redirect to respond 156 | protocol-binding 157 | instant] 158 | :or {instant (t/instant) 159 | request-id (random-request-id) 160 | sig-alg -sig-alg 161 | protocol-binding :redirect}}] 162 | (assert (non-blank-string? acs-url) "acs-url is required") 163 | (assert (non-blank-string? idp-url) "idp-url is required") 164 | (assert (non-blank-string? sp-name) "sp-name is required") 165 | (assert (non-blank-string? issuer) "issuer is required") 166 | (assert (keyword? protocol-binding) "protocol binding must be a keyword") 167 | (redirect-response (authn-request request-id 168 | sp-name 169 | acs-url 170 | idp-url 171 | issuer 172 | state-manager 173 | credential 174 | sig-alg 175 | instant 176 | protocol-binding) 177 | relay-state)) 178 | 179 | (defn idp-logout-redirect-response 180 | "Return Ring response for HTTP 302 redirect." 181 | ([issuer user-email idp-url relay-state] 182 | (idp-logout-redirect-response issuer user-email idp-url relay-state (random-request-id))) 183 | ([issuer user-email idp-url relay-state request-id] 184 | (idp-logout-redirect-response {:issuer issuer 185 | :user-email user-email 186 | :idp-url idp-url 187 | :relay-state relay-state 188 | :request-id request-id})) 189 | ([{:keys [request-id instant idp-url issuer user-email credential relay-state sig-alg] 190 | :or {instant (t/instant) 191 | request-id (random-request-id) 192 | sig-alg -sig-alg}}] 193 | (let [logout-request (build-logout-obj issuer user-email idp-url instant request-id)] 194 | (redirect-response (setup-message-context logout-request credential sig-alg idp-url) relay-state)))) 195 | 196 | (defn logout-redirect-location 197 | "Return only the URI of the logout redirect." 198 | [& args] 199 | (get-in (idp-logout-redirect-response args) [:headers "location"])) 200 | -------------------------------------------------------------------------------- /src/saml20_clj/specs.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.specs 2 | (:require [clojure.spec.alpha :as s] 3 | [saml20-clj.coerce :as coerce] 4 | [saml20-clj.sp.metadata :as metadata] 5 | [saml20-clj.sp.request :as request] 6 | [saml20-clj.state :as state]) 7 | (:import java.net.URL 8 | javax.security.cert.X509Certificate 9 | org.opensaml.security.credential.Credential 10 | org.w3c.dom.Element)) 11 | 12 | (defn- url? [s] 13 | (try 14 | (URL. s) 15 | true 16 | (catch Exception _ 17 | false))) 18 | 19 | (s/def ::acs-url url?) 20 | (s/def ::idp-url url?) 21 | (s/def ::issuer url?) 22 | (s/def ::slo-url url?) 23 | 24 | (s/def ::sp-name string?) 25 | (s/def ::app-name string?) 26 | 27 | (s/def ::state-manager (partial satisfies? state/StateManager)) 28 | (s/def ::credential (partial instance? Credential)) 29 | (s/def ::instant inst?) 30 | 31 | (s/def ::saml-request (partial satisfies? coerce/SerializeXMLString)) 32 | (s/def ::relay-state string?) 33 | 34 | (s/def ::status int?) 35 | (s/def ::headers map?) 36 | (s/def ::body string?) 37 | 38 | (s/def ::sp-cert (partial instance? X509Certificate)) 39 | (s/def ::requests-signed boolean?) 40 | (s/def ::want-assertions-signed boolean?) 41 | 42 | (s/def ::request (s/keys :req-un [::sp-name 43 | ::acs-url 44 | ::idp-url 45 | ::issuer] 46 | :opt-un [::state-manager 47 | ::credential 48 | ::instant])) 49 | 50 | (s/def ::ring-response (s/keys :req-un [::status ::headers ::body])) 51 | 52 | (s/def ::metadata (s/keys :req-un [::acs-url 53 | ::app-name 54 | ::sp-cert] 55 | :opt-un [::requests-signed 56 | ::slo-url 57 | ::want-assertions-signed])) 58 | 59 | (s/fdef metadata/metadata 60 | :args (s/cat :args ::metadata) 61 | :ret string?) 62 | 63 | (s/fdef request/request 64 | :args (s/cat :request ::request) 65 | :ret (partial instance? Element)) 66 | 67 | (s/fdef request/id-redirect-response 68 | :args (s/cat :request ::saml-request 69 | :idp-url ::idp-url 70 | :relay-state ::relay-state) 71 | :ret ::ring-response) 72 | -------------------------------------------------------------------------------- /src/saml20_clj/state.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.state 2 | (:require [java-time.api :as t] 3 | [pretty.core :as pretty])) 4 | 5 | (set! *warn-on-reflection* true) 6 | 7 | (defprotocol StateManager 8 | "Protocol for managing state for recording which requests are in flight, so we can determine whether responses 9 | correspond to valid requests. This library ships with a simple in-memory implementation, but this interface is 10 | provided so that you can provide your own implementation if you need to do something more sophisticated (such as 11 | synchronizing across multiple instances)." 12 | (record-request! [this request-id] 13 | "Called whenever a new request to the IdP goes out. The state manager should record `request-id` (and probably the 14 | current timestamp as well) so it can be used for validating responses.") 15 | 16 | ;; TODO -- consider renaming this to handle-response! or something else clearer 17 | (accept-response! [this request-id] 18 | "Called whenever a new response from IdP is received. The state manager should verify that `request-id` was 19 | actually issued by us (e.g., one we've seen earlier when `record-request!`), and (hopefully) that it is not too old; 20 | if the response is not acceptable, it must throw an Exception. The state manager should remove the request from its 21 | state a response with the same ID cannot be used again (e.g. to prevent replay attacks).")) 22 | 23 | ;; in-memory-state-manager state works like this: 24 | ;; 25 | ;; - State consists of three buckets. After every timeout/2 seconds, the oldest bucket is dropped and a new one is 26 | ;; created. Buckets are thus: 27 | ;; 28 | ;; 1. Requests created after last rotation. Thus requests in this bucket are between 0 and timeout/2 seconds old. 29 | ;; 30 | ;; 2. Requests that have survived one rotation. Requests in this bucket are between ~0 and timeout seconds old. (They 31 | ;; can be ~0 if they were added to the bucket immediately before it was rotated, and rotation just occurred; or 32 | ;; ~timeout if they were added to the bucket when it was first created and the next rotation is about to occur). 33 | ;; 34 | ;; 3. Requests that have survived two rotations. Requests in this bucket are at least timeout/2 seconds old, and at 35 | ;; most (timeout*1.5) seconds old. 36 | ;; 37 | ;; Thus after the two rotations we know a request is at least timeout/2 seconds old, and after three we know it is 38 | ;; older than timeout and can drop it. 39 | ;; 40 | ;; buckets look like: [bucket-created-instant #{request-id}] 41 | ;; 42 | ;; Note that this means `timeout` means the earliest that a request ID gets dropped, but does not guarantee it will be 43 | ;; dropped by then; it make take up to timeout*1.5. 44 | 45 | (defn- prune-buckets [state request-timeout-seconds] 46 | (let [now (t/instant) 47 | [[bucket-1-created :as bucket-1] bucket-2] state] 48 | (letfn [(new-bucket [] 49 | [now #{}])] 50 | (cond 51 | ;; state not initialized yet. 52 | (not bucket-1) 53 | [(new-bucket)] 54 | 55 | ;; all buckets are too old 56 | (t/before? bucket-1-created (t/minus now (t/seconds request-timeout-seconds))) 57 | [(new-bucket)] 58 | 59 | ;; bucket 1 is past the threshold and it's time to rotate the buckets 60 | (t/before? bucket-1-created (t/minus now (t/seconds (int (/ request-timeout-seconds 2))))) 61 | [(new-bucket) bucket-1 bucket-2] 62 | 63 | ;; not time to rotate the buckets yet 64 | :else 65 | state)))) 66 | 67 | (defn- in-memory-state-manager-record-request [state request-timeout-seconds request-id] 68 | (let [state (prune-buckets state request-timeout-seconds)] 69 | (update-in state [0 1] conj request-id))) 70 | 71 | (defn- in-memory-state-manager-accept-response [state request-timeout-seconds request-id] 72 | (let [state (prune-buckets state request-timeout-seconds)] 73 | (or (some (fn [bucket-index] 74 | (when (contains? (get-in state [bucket-index 1]) request-id) 75 | (update-in state [bucket-index 1] disj request-id))) 76 | [0 1 2]) 77 | (throw (ex-info "Invalid request ID" {:request-id request-id}))))) 78 | 79 | ;; 5 minutes, in case people decide they want to sit around on the IdP page for a bit. 80 | (def ^:private default-request-timeout-seconds 300) 81 | 82 | (defn in-memory-state-manager 83 | "A simple in-memory state manager, suitable for a single instance. Requests IDs are considered valid for a minimum of 84 | `request-timeout-seconds`." 85 | ([] 86 | (in-memory-state-manager default-request-timeout-seconds)) 87 | 88 | ([request-timeout-seconds] 89 | (in-memory-state-manager request-timeout-seconds [])) 90 | 91 | ([request-timeout-seconds initial-state] 92 | (let [state (atom initial-state)] 93 | (reify 94 | pretty/PrettyPrintable 95 | (pretty [_] 96 | (list `in-memory-state-manager request-timeout-seconds @state)) 97 | 98 | StateManager 99 | (record-request! [_ request-id] 100 | (swap! state in-memory-state-manager-record-request request-timeout-seconds request-id)) 101 | (accept-response! [_ request-id] 102 | (swap! state in-memory-state-manager-accept-response request-timeout-seconds request-id)) 103 | 104 | ;; this is here mostly for convenience and testability: deref the state manager itself to see what's in the 105 | ;; state atom 106 | clojure.lang.IDeref 107 | (deref [_] 108 | @state))))) 109 | -------------------------------------------------------------------------------- /src/saml20_clj/xml.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.xml 2 | (:require [saml20-clj.encode-decode :as encode-decode]) 3 | (:import [javax.xml.parsers DocumentBuilder DocumentBuilderFactory] 4 | javax.xml.XMLConstants 5 | org.w3c.dom.Document)) 6 | 7 | (set! *warn-on-reflection* true) 8 | 9 | (defn- document-builder 10 | ^DocumentBuilder [] 11 | (.newDocumentBuilder 12 | (doto (DocumentBuilderFactory/newInstance) 13 | (.setNamespaceAware true) 14 | (.setFeature "http://xml.org/sax/features/external-parameter-entities" false) 15 | (.setFeature "http://apache.org/xml/features/nonvalidating/load-external-dtd" false) 16 | (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true) 17 | (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) 18 | (.setXIncludeAware false) 19 | (.setExpandEntityReferences false)))) 20 | 21 | (defn clone-document 22 | "Return a clone of the provided XML document." 23 | [^org.w3c.dom.Document document] 24 | (when document 25 | (let [clone (.. (DocumentBuilderFactory/newInstance) newDocumentBuilder newDocument) 26 | original-root (.getDocumentElement document) 27 | root-copy (.importNode clone original-root true)] 28 | (.appendChild clone root-copy) 29 | clone))) 30 | 31 | (defn str->xmldoc 32 | "Parse a string into an XML `Document`." 33 | ^Document [^String s] 34 | (let [document (document-builder)] 35 | (with-open [is (java.io.ByteArrayInputStream. (encode-decode/str->bytes s))] 36 | (.parse document is)))) 37 | -------------------------------------------------------------------------------- /test/saml20_clj/coerce_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.coerce-test 2 | (:require [clojure.test :refer :all] 3 | [saml20-clj.coerce :as coerce] 4 | [saml20-clj.test :as test])) 5 | 6 | (defn- key-fingerprint [^java.security.Key k] 7 | (when k 8 | (org.apache.commons.codec.digest.DigestUtils/md5Hex (.getEncoded k)))) 9 | 10 | (deftest ->PrivateKey-test 11 | (is (= nil (coerce/->PrivateKey nil))) 12 | (letfn [(is-key-with-fingerprint? [input] 13 | (let [k (coerce/->PrivateKey input)] 14 | (is (instance? java.security.PrivateKey k)) 15 | (is (= "af284d1f7bfa789c787f689a95604d31" 16 | (key-fingerprint k)))))] 17 | (testing "Should be able to get a private key from base-64-encoded string" 18 | (is-key-with-fingerprint? test/sp-private-key)) 19 | (testing "Should be able to get a private key from a Java keystore" 20 | (is-key-with-fingerprint? {:filename test/keystore-filename 21 | :password test/keystore-password 22 | :alias "sp"})) 23 | (testing "Should be able to get a private key from X509Credential" 24 | (is-key-with-fingerprint? (coerce/->Credential test/sp-cert test/sp-private-key))))) 25 | 26 | (def ^:private test-certificate-str-1 27 | "MIIDsjCCApqgAwIBAgIGAWtM1OOxMA0GCSqGSIb3DQEBCwUAMIGZMQswCQYDVQQGEwJVUzETMBEG 28 | A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU 29 | MBIGA1UECwwLU1NPUHJvdmlkZXIxGjAYBgNVBAMMEW1ldGFiYXNlLXZpY3RvcmlhMRwwGgYJKoZI 30 | hvcNAQkBFg1pbmZvQG9rdGEuY29tMB4XDTE5MDYxMjE3NTQ0OFoXDTI5MDYxMjE3NTU0OFowgZkx 31 | CzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2Nv 32 | MQ0wCwYDVQQKDARPa3RhMRQwEgYDVQQLDAtTU09Qcm92aWRlcjEaMBgGA1UEAwwRbWV0YWJhc2Ut 33 | dmljdG9yaWExHDAaBgkqhkiG9w0BCQEWDWluZm9Ab2t0YS5jb20wggEiMA0GCSqGSIb3DQEBAQUA 34 | A4IBDwAwggEKAoIBAQCJNDIHd05aBXALoQStEvErsnJZDx1PIHTYGDY30SGHad8vXANg+tpThny3 35 | ZMmGx8j3tDDwjsijPa8SQtL8I8GrTKO1h2zqM+3sKrgyLk6fcXnKWBqbFx9gpqz9bRxT76WKYTxV 36 | 3t71GtVb8fSfns1fv3u3thsUADDcJmOK65snwirtahie61IDIvoRxMIInu26kw1gCFtOcidoY0yL 37 | RhGgaMjgGYOd2auW5A7bQV9kxePLg8o8rU+KXhTbuHJg0dgW8gVNAv5IKEQQ1VZNTjALR+N6Mca1 38 | p0tuofEVggkA7x9t0O+xWXxUrbSs9C1DxKkxF4xI0z8M/ocqdtwPxNP5AgMBAAEwDQYJKoZIhvcN 39 | AQELBQADggEBAIO5cVa/P50nXuXaMK/klblZ+1MFbJ8Ti86TSPcdnxYO8nbWwQuUwKKuRHf6y5li 40 | 7ctaeXhMfyx/rGsYH4TDgzZhpZmGgZmAKGohDH4YxHctqyxNpRPwJe2kIkJN5yEqLUPNwqm2I7Dw 41 | PcmkewOYEf71Y/sBF0/vRJev5n3upo2nW9RzUz9ptAtWn7EoLsN+grcohJpygj7jiJmbicxblNqF 42 | uvuZkzz+X+qt2W/1mbVDyuIwsvUQOeRbpM+xv11dxheLRKt3kB8Gf6kqd8EjBtHmMFL8s4fdHyfM 43 | eRzAWU6exmsx49oEvw5LrBSTJ97ekvVFfrEASyd96sgeV2Nl0No=") 44 | 45 | (def ^:private test-certificate-str-2 46 | "-----BEGIN CERTIFICATE----- 47 | MIICEjCCAXsCAg36MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG 48 | A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE 49 | MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl 50 | YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw 51 | ODIyMDUyNjU0WhcNMTcwODIxMDUyNjU0WjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE 52 | CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs 53 | ZS5jb20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEAm/xmkHmEQrurE/0re/jeFRLl 54 | 8ZPjBop7uLHhnia7lQG/5zDtZIUC3RVpqDSwBuw/NTweGyuP+o8AG98HxqxTBwID 55 | AQABMA0GCSqGSIb3DQEBBQUAA4GBABS2TLuBeTPmcaTaUW/LCB2NYOy8GMdzR1mx 56 | 8iBIu2H6/E2tiY3RIevV2OW61qY2/XRQg7YPxx3ffeUugX9F4J/iPnnu1zAxxyBy 57 | 2VguKv4SWjRFoRkIfIlHX0qVviMhSlNy2ioFLy7JcPZb+v3ftDGywUqcBiVDoea0 58 | Hn+GmxZA 59 | -----END CERTIFICATE-----") 60 | 61 | (def ^:private test-certificate-str-3 62 | "-----BEGIN CERTIFICATE----- 63 | MIIC2jCCAkMCAg38MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG 64 | A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE 65 | MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl 66 | YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw 67 | ODIyMDUyNzQxWhcNMTcwODIxMDUyNzQxWjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE 68 | CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs 69 | ZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0z9FeMynsC8+u 70 | dvX+LciZxnh5uRj4C9S6tNeeAlIGCfQYk0zUcNFCoCkTknNQd/YEiawDLNbxBqut 71 | bMDZ1aarys1a0lYmUeVLCIqvzBkPJTSQsCopQQ9V8WuT252zzNzs68dVGNdCJd5J 72 | NRQykpwexmnjPPv0mvj7i8XgG379TyW6P+WWV5okeUkXJ9eJS2ouDYdR2SM9BoVW 73 | +FgxDu6BmXhozW5EfsnajFp7HL8kQClI0QOc79yuKl3492rH6bzFsFn2lfwWy9ic 74 | 7cP8EpCTeFp1tFaD+vxBhPZkeTQ1HKx6hQ5zeHIB5ySJJZ7af2W8r4eTGYzbdRW2 75 | 4DDHCPhZAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAQMv+BFvGdMVzkQaQ3/+2noVz 76 | /uAKbzpEL8xTcxYyP3lkOeh4FoxiSWqy5pGFALdPONoDuYFpLhjJSZaEwuvjI/Tr 77 | rGhLV1pRG9frwDFshqD2Vaj4ENBCBh6UpeBop5+285zQ4SI7q4U9oSebUDJiuOx6 78 | +tZ9KynmrbJpTSi0+BM= 79 | -----END CERTIFICATE-----") 80 | 81 | (def ^:private test-certificate-str-4 82 | "-----BEGIN CERTIFICATE----- 83 | MIID2jCCA0MCAg39MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG 84 | A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE 85 | MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl 86 | YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw 87 | ODIyMDUyODAwWhcNMTcwODIxMDUyODAwWjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE 88 | CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs 89 | ZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwvWITOLeyTbS1 90 | Q/UacqeILIK16UHLvSymIlbbiT7mpD4SMwB343xpIlXN64fC0Y1ylT6LLeX4St7A 91 | cJrGIV3AMmJcsDsNzgo577LqtNvnOkLH0GojisFEKQiREX6gOgq9tWSqwaENccTE 92 | sAXuV6AQ1ST+G16s00iN92hjX9V/V66snRwTsJ/p4WRpLSdAj4272hiM19qIg9zr 93 | h92e2rQy7E/UShW4gpOrhg2f6fcCBm+aXIga+qxaSLchcDUvPXrpIxTd/OWQ23Qh 94 | vIEzkGbPlBA8J7Nw9KCyaxbYMBFb1i0lBjwKLjmcoihiI7PVthAOu/B71D2hKcFj 95 | Kpfv4D1Uam/0VumKwhwuhZVNjLq1BR1FKRJ1CioLG4wCTr0LVgtvvUyhFrS+3PdU 96 | R0T5HlAQWPMyQDHgCpbOHW0wc0hbuNeO/lS82LjieGNFxKmMBFF9lsN2zsA6Qw32 97 | Xkb2/EFltXCtpuOwVztdk4MDrnaDXy9zMZuqFHpv5lWTbDVwDdyEQNclYlbAEbDe 98 | vEQo/rAOZFl94Mu63rAgLiPeZN4IdS/48or5KaQaCOe0DuAb4GWNIQ42cYQ5TsEH 99 | Wt+FIOAMSpf9hNPjDeu1uff40DOtsiyGeX9NViqKtttaHpvd7rb2zsasbcAGUl+f 100 | NQJj4qImPSB9ThqZqPTukEcM/NtbeQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAIAi 101 | gU3My8kYYniDuKEXSJmbVB+K1upHxWDA8R6KMZGXfbe5BRd8s40cY6JBYL52Tgqd 102 | l8z5Ek8dC4NNpfpcZc/teT1WqiO2wnpGHjgMDuDL1mxCZNL422jHpiPWkWp3AuDI 103 | c7tL1QjbfAUHAQYwmHkWgPP+T2wAv0pOt36GgMCM 104 | -----END CERTIFICATE-----") 105 | 106 | (deftest ->X509Certificate-test 107 | (testing "from String" 108 | (testing "make sure we can parse a certificate, no armor" 109 | (coerce/->X509Certificate test-certificate-str-1) 110 | (is (instance? java.security.cert.X509Certificate 111 | (coerce/->X509Certificate test-certificate-str-1)))) 112 | (testing "make sure we can parse a certificate with armor 512b key" 113 | (is (instance? java.security.cert.X509Certificate 114 | (coerce/->X509Certificate test-certificate-str-2)))) 115 | (testing "make sure we can parse a certificate with armor 2048b key" 116 | (is (instance? java.security.cert.X509Certificate 117 | (coerce/->X509Certificate test-certificate-str-3)))) 118 | (testing "make sure we can parse a certificate with armor 4096b key" 119 | (is (instance? java.security.cert.X509Certificate 120 | (coerce/->X509Certificate test-certificate-str-4)))))) 121 | 122 | (defn- x509-credential-fingerprints [^org.opensaml.security.x509.X509Credential credential] 123 | {:public (key-fingerprint (.getPublicKey credential)) 124 | :private (key-fingerprint (.getPrivateKey credential))}) 125 | 126 | (deftest ->Credential-test 127 | (let [sp-fingerprints {:public "6e104aaa6daccb9c8f2b4d692441f3a5" 128 | :private "af284d1f7bfa789c787f689a95604d31"} 129 | idp-fingerprints {:public "b2648dc4aa28760eaf33c789d58ba262", :private nil}] 130 | (testing "Should be able to get an X509Credential from Strings" 131 | (is (= sp-fingerprints 132 | (x509-credential-fingerprints (coerce/->Credential test/sp-cert test/sp-private-key))))) 133 | (testing "Should accept a tuple of [public-key private-key]" 134 | (is (= sp-fingerprints 135 | (x509-credential-fingerprints (coerce/->Credential [test/sp-cert test/sp-private-key])))) 136 | (is (= idp-fingerprints 137 | (x509-credential-fingerprints (coerce/->Credential [test/idp-cert]))))) 138 | (testing "Should be able to get X509Credential from a keystore" 139 | (testing "public only" 140 | (is (= idp-fingerprints 141 | (x509-credential-fingerprints (coerce/->Credential {:filename test/keystore-filename 142 | :password test/keystore-password 143 | :alias "idp"}))))) 144 | (testing "public + private" 145 | (is (= sp-fingerprints 146 | (x509-credential-fingerprints (coerce/->Credential {:filename test/keystore-filename 147 | :password test/keystore-password 148 | :alias "sp"})))))))) 149 | 150 | (deftest ->LogoutResponse 151 | (let [logout-response-ring {:params {:SAMLResponse "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOkxvZ291dFJlc3BvbnNlIERlc3RpbmF0aW9uPSJodHRwOi8vbG9jYWxob3N0OjMwMDAvYXV0aC9zc28vaGFuZGxlX3NsbyIgSUQ9ImlkODYyMTQxMDMzODM0ODEzMDA4NTY4NzAiIEluUmVzcG9uc2VUbz0iaWQ2NjFiYWM5ZC0xYWMyLTQxNjctOTY0Ni05ZjEyMmY3ODhkMmYiIElzc3VlSW5zdGFudD0iMjAyNS0wMi0yNFQxNzoxOTo1Ny42MTBaIiBWZXJzaW9uPSIyLjAiIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIj48c2FtbDI6SXNzdWVyIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5IiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI+aHR0cDovL3d3dy5va3RhLmNvbS9leGtuZnpoMXA1TlhBTm96MTVkNzwvc2FtbDI6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPjxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZHNpZy1tb3JlI3JzYS1zaGEyNTYiLz48ZHM6UmVmZXJlbmNlIFVSST0iI2lkODYyMTQxMDMzODM0ODEzMDA4NTY4NzAiPjxkczpUcmFuc2Zvcm1zPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48L2RzOlRyYW5zZm9ybXM+PGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3NoYTI1NiIvPjxkczpEaWdlc3RWYWx1ZT5yeGt3RVJSRkNVVklyTXdBZDBoMnJ3bE5PeDVaK1UvZzZiWkUrVHpSVlNVPTwvZHM6RGlnZXN0VmFsdWU+PC9kczpSZWZlcmVuY2U+PC9kczpTaWduZWRJbmZvPjxkczpTaWduYXR1cmVWYWx1ZT5jeXdMRW5kdVF6b3VSa3k4K2hNVXkrMUtBVS9Xb2pRcDJDcTZmMmVrNlFyM2hvbGYvcUt6dkpjNVBOTzBSSjh3UTdvdVlGYmR4V0s0Q0VobzQ0Qy9sOTJSZTl6V3djcXdUWjA5WWdKRFNYUjU2NXRsT2VjQ2pqNS9kd05hRUkrNUEzdGVIbC9GMk5qMDdrUGRtSThhWlMyQ2tJOTk4aXoxclV4bVRqSTJIQm9td3QxZ04vQ1NaNys4d2lWZkRmOGVycmZ0SFhGUmhkMzdRTzBob0NmeVlUY2R0b0RGQitTZmxsSCtpRHVyeE8vV2NkMTZoUEJRQ0Z6bW9tdHAwZHkxMW80NlZmMVFwNUlhMEt4allKOU1tNmxkVUE2dHVYUW40aTYzZXI0MkVNZjAzRTFDYUZrZlowRXROU2ZmY1A3UUhZeTk0OHpIcG1vcEprU3UwV0NsNVE9PTwvZHM6U2lnbmF0dXJlVmFsdWU+PGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRHFEQ0NBcENnQXdJQkFnSUdBWlVhd0VSWE1BMEdDU3FHU0liM0RRRUJDd1VBTUlHVU1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFRwpBMVVFQ0F3S1EyRnNhV1p2Y201cFlURVdNQlFHQTFVRUJ3d05VMkZ1SUVaeVlXNWphWE5qYnpFTk1Bc0dBMVVFQ2d3RVQydDBZVEVVCk1CSUdBMVVFQ3d3TFUxTlBVSEp2ZG1sa1pYSXhGVEFUQmdOVkJBTU1ER1JsZGkwd09EVTBPREl5TlRFY01Cb0dDU3FHU0liM0RRRUoKQVJZTmFXNW1iMEJ2YTNSaExtTnZiVEFlRncweU5UQXlNVGd5TURJNE1qSmFGdzB6TlRBeU1UZ3lNREk1TWpKYU1JR1VNUXN3Q1FZRApWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVXTUJRR0ExVUVCd3dOVTJGdUlFWnlZVzVqYVhOamJ6RU5NQXNHCkExVUVDZ3dFVDJ0MFlURVVNQklHQTFVRUN3d0xVMU5QVUhKdmRtbGtaWEl4RlRBVEJnTlZCQU1NREdSbGRpMHdPRFUwT0RJeU5URWMKTUJvR0NTcUdTSWIzRFFFSkFSWU5hVzVtYjBCdmEzUmhMbU52YlRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQwpnZ0VCQU8zbkhxZUMraXRINmdyUytvSEdMMEVvNXZUR2EycWQ5VzFyWkplcW1BcWJwZXk0WnY2c0VhSEVqL1FJTEVVbGVNOEl5YTJvClErdkxiWTFJU05Fb0R2TCt1MmZDM0NGWjE1VlRnb0hmdEhZOFF5K21vdW1pWjZyQWU2MzdSY1BQT0RmRXlSUzRhY2FZa29TQ0g1UDQKbEtkVnVOQTc2UXN6KzQrelNnbGNmMURmT0JhQ3FuRHJWWXUrbGVaTWxSSVJaL3ZZRW8zT012ejZXTGJnUy9KMXAra2xkZDJGanpFdQp5ZzdRYiszOGZCZ3pkREhSYmZUeGQzRVptTThFblpxQ0tIWklna3ZVZXBaWUp3TlVXM3FVR3dlR0Y0c0JQaWNnQnI2RU0rR2RJeWVmCnFZNzlEV2h4RVhIOEdhMzA2Yzk4L29KajBiUHBlRFVwb001OWZEN3QyekVDQXdFQUFUQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUEKQ3VmRnN5NHNGSkhvNk5XMFpSUy9RT0lFWTFVYjNZNGlHQytIM0tXemdsRDJqNTA1N0tqb3U1ZDNvSmIwSDB0OEpJK0tLOUhIMGk5YwpkRldyQXQ2OTZ1MFpmUEk2TVNWV2x5bVQ5WWY4ZkV1VW9xTmlqQ0RtcW96TlhINUpLQUM2TVZTNzdWZXY0amMxdHJFQmVxd0o5ZE5YCnpFMXBCUDh4YnpWSzBET0NQRW5EL0p4eHQyWmR4d1hiZjlCOWUyeGRTNWYrUG8vZjdCbDkrTVoxeWUyR1ZGV1J0cEJmZzUwU2pFdWYKMThMT2NsRjdibGhZZ1g0SnA4TFJVaGp4cVdUb0Qxc1B3QUxwTmw5SkJ5bGJNb2w2QUlLNkxURG8rMitScUNlQzdjU0FZcjY3SXY0cwppQ0RpbU9DMWlkR01vcU1QT1pOTXBmYXZUemxNeFptalAxQmhiUT09PC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWwycDpTdGF0dXMgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiPjxzYW1sMnA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1sMnA6U3RhdHVzPjwvc2FtbDJwOkxvZ291dFJlc3BvbnNlPg==" 152 | :RelayState "aHR0cDovL2xvY2FsaG9zdDozMDAwL2F1dGgvc3NvL2hhbmRsZV9zbG8="} 153 | :content-type "application/x-www-form-urlencoded" 154 | :request-method :post}] 155 | (testing "converts ring response into logout response object" 156 | (is (instance? org.opensaml.saml.saml2.core.LogoutResponse 157 | (coerce/->LogoutResponse logout-response-ring)))))) 158 | -------------------------------------------------------------------------------- /test/saml20_clj/crypto_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.crypto-test 2 | (:require [clojure.test :refer :all] 3 | [saml20-clj.coerce :as coerce] 4 | [saml20-clj.crypto :as crypto] 5 | [saml20-clj.test :as test]) 6 | (:import org.opensaml.saml.common.messaging.context.SAMLPeerEntityContext)) 7 | 8 | (deftest assert-signature-invalid-swapped-signature 9 | (doseq [{:keys [response], :as response-map} (test/responses) 10 | :when (test/malicious-signature? response-map)] 11 | (testing (test/describe-response-map response-map) 12 | (is (thrown-with-msg? 13 | clojure.lang.ExceptionInfo 14 | #"Signature does not match credential" 15 | (crypto/assert-signature-valid-when-present response test/idp-cert)))))) 16 | 17 | (deftest has-private-key-test 18 | (testing "has private key" 19 | (is (= true (crypto/has-private-key? {:filename test/keystore-filename 20 | :password test/keystore-password 21 | :alias "sp"}))) 22 | 23 | (is (= true (crypto/has-private-key? test/sp-private-key))) 24 | 25 | (testing "has only public key" 26 | (is (= false (crypto/has-private-key? {:filename test/keystore-filename 27 | :password test/keystore-password 28 | :alias "idp"})))))) 29 | 30 | (deftest handle-signature-security-test 31 | (testing "with signed LogoutResponse POST bindings" 32 | (let [request (test/ring-logout-response-post :success "relay-state" :signature true) 33 | msg-ctx (coerce/ring-request->MessageContext request)] 34 | (crypto/handle-signature-security msg-ctx "http://idp.example.com/metadata.php" test/idp-cert request) 35 | (is (.isAuthenticated (.getSubcontext msg-ctx SAMLPeerEntityContext))))) 36 | 37 | (testing "with signed LogoutResponse Redirect bindings" 38 | (let [request (test/ring-logout-response-get :success :signature true) 39 | msg-ctx (coerce/ring-request->MessageContext request)] 40 | (crypto/handle-signature-security msg-ctx "http://idp.example.com/metadata.php" test/idp-cert request) 41 | (is (.isAuthenticated (.getSubcontext msg-ctx SAMLPeerEntityContext))))) 42 | 43 | (testing "with unsigned LogoutResponse POST bindings" 44 | (let [request (test/ring-logout-response-post :success "relay-state" :signature false) 45 | msg-ctx (coerce/ring-request->MessageContext request)] 46 | (crypto/handle-signature-security msg-ctx "http://idp.example.com/metadata.php" test/idp-cert request) 47 | (is (not (.isAuthenticated (.getSubcontext msg-ctx SAMLPeerEntityContext)))))) 48 | 49 | (testing "with unsigned LogoutResponse Redirect bindings" 50 | (let [request (test/ring-logout-response-get :success :signature false) 51 | msg-ctx (coerce/ring-request->MessageContext request)] 52 | (crypto/handle-signature-security msg-ctx "http://idp.example.com/metadata.php" test/idp-cert request) 53 | (is (not (.isAuthenticated (.getSubcontext msg-ctx SAMLPeerEntityContext))))))) 54 | -------------------------------------------------------------------------------- /test/saml20_clj/runners/test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.runners.test 2 | (:refer-clojure :exclude [test]) 3 | (:require [cognitect.test-runner.api :as test-runner] 4 | [pjstadig.humane-test-output :as humane-test-output])) 5 | 6 | (humane-test-output/activate!) 7 | 8 | (defn test [& args] 9 | (apply test-runner/test args)) 10 | -------------------------------------------------------------------------------- /test/saml20_clj/sp/logout_response_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.logout-response-test 2 | (:require [clojure.test :as t] 3 | [saml20-clj.sp.logout-response :as sut] 4 | [saml20-clj.test :as test])) 5 | 6 | (t/deftest test-validate-response 7 | (t/testing "raise with an incorrect issuer response" 8 | (let [response (test/ring-logout-response-post :success "relay-state") 9 | exception (try 10 | (sut/validate-logout response test/logout-request-id "http://idp.incorrect.example.org/" test/idp-cert) 11 | (catch clojure.lang.ExceptionInfo e 12 | {:msg (ex-message e) :data (ex-data e)}))] 13 | (t/is (not (instance? org.opensaml.saml.saml2.core.LogoutResponse 14 | exception))) 15 | (t/is (= {:msg "Message failed to validate issuer" 16 | :data {:validator :issuer 17 | :expected "http://idp.incorrect.example.org/" 18 | :actual "http://idp.example.com/metadata.php"}} 19 | exception)))) 20 | (t/testing "raise with a broken signature response get" 21 | (let [response (test/ring-logout-response-get :success :signature :bad) 22 | exception (try 23 | (sut/validate-logout response test/logout-request-id test/logout-issuer-id test/idp-cert) 24 | (catch clojure.lang.ExceptionInfo e 25 | {:msg (ex-message e) :data (ex-data e)}))] 26 | (t/is (not (instance? org.opensaml.saml.saml2.core.LogoutResponse 27 | exception))) 28 | (t/is (= {:msg "Message failed to validate signature" 29 | :data {:validator :signature}} 30 | exception)))) 31 | (t/testing "raise with a broken signature response" 32 | (let [response (test/ring-logout-response-post :success "relay-state" :signature :bad) 33 | exception (try 34 | (sut/validate-logout response test/logout-request-id test/logout-issuer-id test/idp-cert) 35 | (catch clojure.lang.ExceptionInfo e 36 | {:msg (ex-message e) :data (ex-data e)}))] 37 | (t/is (not (instance? org.opensaml.saml.saml2.core.LogoutResponse 38 | exception))) 39 | (t/is (= {:msg "Message failed to validate signature" 40 | :data {:validator :signature}} 41 | exception)))) 42 | (t/testing "raise with an unsigned response" 43 | (let [response (test/ring-logout-response-post :success "relay-state" :signature false) 44 | exception (try 45 | (sut/validate-logout response test/logout-request-id test/logout-issuer-id test/idp-cert) 46 | (catch clojure.lang.ExceptionInfo e 47 | {:msg (ex-message e) :data (ex-data e)}))] 48 | (t/is (not (instance? org.opensaml.saml.saml2.core.LogoutResponse 49 | exception))) 50 | (t/is (= {:msg "Message is not Authenticated" 51 | :data {:validator :require-authenticated 52 | :is-authenticated false}} 53 | exception)))) 54 | (t/testing "returns a logout-response without raising" 55 | (let [response (test/ring-logout-response-post :success "relay-state")] 56 | (t/is (instance? org.opensaml.saml.saml2.core.LogoutResponse 57 | (sut/validate-logout response test/logout-request-id test/logout-issuer-id test/idp-cert))))) 58 | (t/testing "returns a logout-response without raising with get" 59 | (let [response (test/ring-logout-response-get :success)] 60 | (t/is (instance? org.opensaml.saml.saml2.core.LogoutResponse 61 | (sut/validate-logout response test/logout-request-id test/logout-issuer-id test/idp-cert)))))) 62 | 63 | (t/deftest test-success? 64 | (t/testing "when the logout-response is successful" 65 | (let [response (-> (test/ring-logout-response-post :success "relay-state") 66 | (sut/validate-logout test/logout-request-id test/logout-issuer-id test/idp-cert))] 67 | (t/is (sut/logout-success? response)))) 68 | (t/testing "when the logout-response is not successful" 69 | (let [response (-> (test/ring-logout-response-post :authnfailed "relay-state") 70 | (sut/validate-logout test/logout-request-id test/logout-issuer-id test/idp-cert))] 71 | (t/is (not (sut/logout-success? response)))))) 72 | -------------------------------------------------------------------------------- /test/saml20_clj/sp/metadata_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.metadata-test 2 | (:require [clojure.test :as t] 3 | [saml20-clj.coerce :as coerce] 4 | [saml20-clj.sp.metadata :as sut] 5 | [saml20-clj.test :as test])) 6 | 7 | (t/deftest metadata-generation 8 | (t/testing "generates metadata with keyinfo" 9 | (t/is (= test/metadata-with-key-info 10 | (saml20-clj.sp.metadata/metadata {:app-name "metabase" 11 | :acs-url "http://acs.example.com" 12 | :slo-url "http://slo.example.com" 13 | :sp-cert (coerce/->Credential test/sp-cert)})))) 14 | (t/testing "generates metadata with-out keyinfo" 15 | (t/is (= test/metadata-without-key-info 16 | (saml20-clj.sp.metadata/metadata {:app-name "metabase" 17 | :acs-url "http://acs.example.com" 18 | :slo-url "http://slo.example.com"}))))) 19 | -------------------------------------------------------------------------------- /test/saml20_clj/sp/request_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.sp.request-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [java-time.api :as t] 4 | [saml20-clj.sp.request :as request] 5 | [saml20-clj.test :as test])) 6 | 7 | (def target-uri "http://sp.example.com/demo1/index.php?acs") 8 | 9 | (deftest idp-redirect-response-test 10 | (t/with-clock (t/mock-clock (t/instant "2020-09-24T22:51:00.000Z")) 11 | (testing "without signature" 12 | (let [request {:request-id "ONELOGIN_809707f0030a5d00620c9d9df97f627afe9dcc24" 13 | :sp-name "SP test" 14 | :acs-url "http://sp.example.com/demo1/index.php?acs" 15 | :idp-url "http://idp.example.com/SSOService.php" 16 | :issuer "http://sp.example.com/demo1/metadata.php" 17 | :relay-state target-uri}] 18 | (is (= {:status 302, 19 | :body "", 20 | :headers 21 | {"Cache-control" "no-cache, no-store" 22 | "Pragma" "no-cache" 23 | "location" (str 24 | "http://idp.example.com/SSOService.php?SAMLRequest=" 25 | "fVLLbtswEPwVgndJFPNwRFg2nLppDbi2YDk59FIw5KomIJEqlz" 26 | "Lcv4%2BsKEFyiK%2B7OzuzMzudn5qaHMGjcTanacwoAaucNvZv" 27 | "Th%2F3D9Ednc%2BmKJuat2LRhYPdwb8OMJAeaFG8dnLaeSucRI" 28 | "PCygZQBCXKxa%2B14DETrXfBKVdTskAEH3qqb85i14AvwR%2BN" 29 | "gsfdOqeHEFqRJNjGcJJNW0OsXJNoaFyaGKvhFLeHdi4VUrLsBR" 30 | "grwyB6xBn9GViW23H7GUfJapnT7eb7evtjtflzx7IJm1SMXTF5" 31 | "oxm75UxlOtNVNqlu%2BURWkGml%2BHUPw0IimiPktJI1wrmCHa" 32 | "wsBmlDTjnjLGJZxK%2F3nIubVDAWM8Z%2BU1KMZ9%2F34gc7L3" 33 | "n0%2FDqE4ud%2BX0Q70MaDCsOSo9HgNz0ip2VBQn86JU9vifVY" 34 | "OuYjBmH%2BYzCXOeVbGnR2yfsGgtQyyLON0%2BQj1ftjnNWtlo" 35 | "WrjfpPHpxvZPiaOo3ToWJ0VA2jorPYgjKVAU2T2cjx%2Bd1mLw" 36 | "%3D%3D&RelayState=http%3A%2F%2Fsp.example.com%2Fde" 37 | "mo1%2Findex.php%3Facs")}} 38 | (request/idp-redirect-response request))))) 39 | (testing "with a signature" 40 | (let [request {:request-id "ONELOGIN_809707f0030a5d00620c9d9df97f627afe9dcc24" 41 | :sp-name "SP test" 42 | :acs-url "http://sp.example.com/demo1/index.php?acs" 43 | :idp-url "http://idp.example.com/SSOService.php" 44 | :issuer "http://sp.example.com/demo1/metadata.php" 45 | :credential test/sp-private-key 46 | :relay-state target-uri}] 47 | (is (= {:status 302, 48 | :body "", 49 | :headers 50 | {"Cache-control" "no-cache, no-store" 51 | "Pragma" "no-cache" 52 | "location" (str 53 | "http://idp.example.com/SSOService.php?SAMLRequest=" 54 | "fVLLbtswEPwVgndJFPNwRFg2nLppDbi2YDk59FIw5KomIJEqlz" 55 | "Lcv4%2BsKEFyiK%2B7OzuzMzudn5qaHMGjcTanacwoAaucNvZv" 56 | "Th%2F3D9Ednc%2BmKJuat2LRhYPdwb8OMJAeaFG8dnLaeSucRI" 57 | "PCygZQBCXKxa%2B14DETrXfBKVdTskAEH3qqb85i14AvwR%2BN" 58 | "gsfdOqeHEFqRJNjGcJJNW0OsXJNoaFyaGKvhFLeHdi4VUrLsBR" 59 | "grwyB6xBn9GViW23H7GUfJapnT7eb7evtjtflzx7IJm1SMXTF5" 60 | "oxm75UxlOtNVNqlu%2BURWkGml%2BHUPw0IimiPktJI1wrmCHa" 61 | "wsBmlDTjnjLGJZxK%2F3nIubVDAWM8Z%2BU1KMZ9%2F34gc7L3" 62 | "n0%2FDqE4ud%2BX0Q70MaDCsOSo9HgNz0ip2VBQn86JU9vifVY" 63 | "OuYjBmH%2BYzCXOeVbGnR2yfsGgtQyyLON0%2BQj1ftjnNWtlo" 64 | "WrjfpPHpxvZPiaOo3ToWJ0VA2jorPYgjKVAU2T2cjx%2Bd1mLw" 65 | "%3D%3D&RelayState=http%3A%2F%2Fsp.example.com%2Fde" 66 | "mo1%2Findex.php%3Facs&SigAlg=http%3A%2F%2Fwww.w3.o" 67 | "rg%2F2000%2F09%2Fxmldsig%23rsa-sha1&Signature=KJSj" 68 | "oD6Mg7OH%2F2pCd6qEDmqSxqWZqOmBePLC5RemNjmLE2ElfnO0" 69 | "tPvTgWDbY7Io5ENEElvsa8eJziZz3TYtFJa1AUDtO2c6BQX627" 70 | "LA7Y0gCvhj035rxJZPPh8ucdTCjNA0roYFpdlQiKQZnUJmJgX2" 71 | "QvB9Zr7WTIEPXMNkb%2B0%3D")}} 72 | (request/idp-redirect-response request))))) 73 | (testing "with a signature and http post binding" 74 | (let [request {:request-id "ONELOGIN_809707f0030a5d00620c9d9df97f627afe9dcc24" 75 | :sp-name "SP test" 76 | :acs-url "http://sp.example.com/demo1/index.php?acs" 77 | :idp-url "http://idp.example.com/SSOService.php" 78 | :issuer "http://sp.example.com/demo1/metadata.php" 79 | :credential test/sp-private-key 80 | :relay-state target-uri 81 | :protocl-binding :post}] 82 | (is (= {:status 302, 83 | :body "", 84 | :headers 85 | {"Cache-control" "no-cache, no-store" 86 | "Pragma" "no-cache" 87 | "location" (str "http://idp.example.com/SSOService.php?SAMLRequest=" 88 | "fVLLbtswEPwVgndJFPNwRFg2nLppDbi2YDk59FIw5KomIJEqlz" 89 | "Lcv4%2BsKEFyiK%2B7OzuzMzudn5qaHMGjcTanacwoAaucNvZv" 90 | "Th%2F3D9Ednc%2BmKJuat2LRhYPdwb8OMJAeaFG8dnLaeSucRI" 91 | "PCygZQBCXKxa%2B14DETrXfBKVdTskAEH3qqb85i14AvwR%2BN" 92 | "gsfdOqeHEFqRJNjGcJJNW0OsXJNoaFyaGKvhFLeHdi4VUrLsBR" 93 | "grwyB6xBn9GViW23H7GUfJapnT7eb7evtjtflzx7IJm1SMXTF5" 94 | "oxm75UxlOtNVNqlu%2BURWkGml%2BHUPw0IimiPktJI1wrmCHa" 95 | "wsBmlDTjnjLGJZxK%2F3nIubVDAWM8Z%2BU1KMZ9%2F34gc7L3" 96 | "n0%2FDqE4ud%2BX0Q70MaDCsOSo9HgNz0ip2VBQn86JU9vifVY" 97 | "OuYjBmH%2BYzCXOeVbGnR2yfsGgtQyyLON0%2BQj1ftjnNWtlo" 98 | "WrjfpPHpxvZPiaOo3ToWJ0VA2jorPYgjKVAU2T2cjx%2Bd1mLw" 99 | "%3D%3D&RelayState=http%3A%2F%2Fsp.example.com%2Fde" 100 | "mo1%2Findex.php%3Facs&SigAlg=http%3A%2F%2Fwww.w3.o" 101 | "rg%2F2000%2F09%2Fxmldsig%23rsa-sha1&Signature=KJSj" 102 | "oD6Mg7OH%2F2pCd6qEDmqSxqWZqOmBePLC5RemNjmLE2ElfnO0" 103 | "tPvTgWDbY7Io5ENEElvsa8eJziZz3TYtFJa1AUDtO2c6BQX627" 104 | "LA7Y0gCvhj035rxJZPPh8ucdTCjNA0roYFpdlQiKQZnUJmJgX2" 105 | "QvB9Zr7WTIEPXMNkb%2B0%3D")}} 106 | (request/idp-redirect-response request))))))) 107 | 108 | (deftest idp-logout-redirect-response-test 109 | (t/with-clock (t/mock-clock (t/instant "2020-09-24T22:51:00.000Z")) 110 | (let [req-id "ONELOGIN_109707f0030a5d00620c9d9df97f627afe9dcc24" 111 | idp-url "http://idp.example.com/SSOService.php" 112 | user-email "user@example.com" 113 | issuer "http://sp.example.com/demo1/metadata.php"] 114 | (testing "without signing" 115 | 116 | (is (= {:status 302 117 | :headers {"Cache-control" "no-cache, no-store" 118 | "Pragma" "no-cache" 119 | "location" (str 120 | "http://idp.example.com/SSOService.php?SAMLRequest=" 121 | "nZFNS8NAEIb%2FSth7k8naNmZpUoWqBGoLTfXgRZbdSRvIfpjd" 122 | "lP580y%2BIHjx4m2F455mHmc2PqgkO2Lra6IzEIZAAtTCy1ruM" 123 | "vG2fR%2Fdkns8cVw21bGl2pvMb%2FOrQ%2BaBPascuo4x0rWaG" 124 | "u9oxzRU65gUrH1%2BXjIbAbGu8EaYhwaIP1pr7M23vvWVRVEsb" 125 | "4pEr22AojIrKcl1ie6gFhnZvSVAsMrJePS3XL8XqM4Y0gaQCuA" 126 | "M%2BkQBTCiKVqazSpJrShFeYSiHouI8512GhnefaZ4QChRGkIz" 127 | "reUsomMQMIAeCDBO83%2Bf5SclVl53A7VPzbkDuH7cmK5Fcr91" 128 | "NKojJxpNBzyT0%2Fic2iIeoGXvWri8W%2FwF1fPQyYN8BlZX5t" 129 | "f30x%2FwY%3D&RelayState=aHR0cDovL3NwLmV4YW1wbGUuY2" 130 | "9tL2RlbW8xL21ldGFkYXRhLnBocA%3D%3D")} 131 | :body ""} 132 | (request/idp-logout-redirect-response 133 | {:issuer issuer 134 | :user-email user-email 135 | :idp-url idp-url 136 | :relay-state (test/str->base64 issuer) 137 | :request-id req-id})))) 138 | (testing "with signing" 139 | (is (= {:status 302 140 | :headers {"Cache-control" "no-cache, no-store" 141 | "Pragma" "no-cache" 142 | "location" (str 143 | "http://idp.example.com/SSOService.php?SAMLRequest=" 144 | "nZFNS8NAEIb%2FSth7k8naNmZpUoWqBGoLTfXgRZbdSRvIfpjd" 145 | "lP580y%2BIHjx4m2F455mHmc2PqgkO2Lra6IzEIZAAtTCy1ruM" 146 | "vG2fR%2Fdkns8cVw21bGl2pvMb%2FOrQ%2BaBPascuo4x0rWaG" 147 | "u9oxzRU65gUrH1%2BXjIbAbGu8EaYhwaIP1pr7M23vvWVRVEsb" 148 | "4pEr22AojIrKcl1ie6gFhnZvSVAsMrJePS3XL8XqM4Y0gaQCuA" 149 | "M%2BkQBTCiKVqazSpJrShFeYSiHouI8512GhnefaZ4QChRGkIz" 150 | "reUsomMQMIAeCDBO83%2Bf5SclVl53A7VPzbkDuH7cmK5Fcr91" 151 | "NKojJxpNBzyT0%2Fic2iIeoGXvWri8W%2FwF1fPQyYN8BlZX5t" 152 | "f30x%2FwY%3D&RelayState=aHR0cDovL3NwLmV4YW1wbGUuY2" 153 | "9tL2RlbW8xL21ldGFkYXRhLnBocA%3D%3D&SigAlg=http%3A%" 154 | "2F%2Fwww.w3.org%2F2000%2F09%2Fxmldsig%23rsa-sha1&S" 155 | "ignature=N1dSZcA1AO6Et3%2BHgYNlyvAGnPuflVWyCerVrES" 156 | "jMLNCt%2F%2BshuUkwI%2BkyHsffbRS0iO0lh1bkIcexOFU8ja" 157 | "%2B3t5YcWsr%2B3AkfUeeNOoReeogKh2qIcU9UaHU7tkUj4SQi" 158 | "B%2BnWqfpueLkI8WaSE2hVBCe0qwiLLY4hvkEI2%2Fz5BI%3D")} 159 | :body ""} 160 | (request/idp-logout-redirect-response 161 | {:issuer issuer 162 | :credential test/sp-private-key 163 | :user-email user-email 164 | :idp-url idp-url 165 | :relay-state (test/str->base64 issuer) 166 | :request-id req-id}))))))) 167 | -------------------------------------------------------------------------------- /test/saml20_clj/state_test.clj: -------------------------------------------------------------------------------- 1 | (ns saml20-clj.state-test 2 | (:require [clojure.test :refer :all] 3 | [java-time.api :as t] 4 | [saml20-clj.coerce :as coerce] 5 | [saml20-clj.sp.request :as request] 6 | [saml20-clj.sp.response :as response] 7 | [saml20-clj.state :as state] 8 | [saml20-clj.test :as test])) 9 | 10 | (deftest in-memory-state-manager-test 11 | (let [m (state/in-memory-state-manager)] 12 | (t/with-clock (t/mock-clock (t/instant "2020-09-25T08:00:00.000Z")) 13 | (testing "record some IDs" 14 | (state/record-request! m 1) 15 | (state/record-request! m 2) 16 | (is (= [[(t/instant "2020-09-25T08:00:00Z") #{1 2}]] 17 | @m)))) 18 | (testing "Move forward to t+1 minute" 19 | (t/with-clock (t/mock-clock (t/instant "2020-09-25T08:01:00.000Z")) 20 | (testing "consume one of the IDs" 21 | (state/accept-response! m 2) 22 | (is (= [[(t/instant "2020-09-25T08:00:00Z") #{1}]] 23 | @m))) 24 | (testing "trying to consume the ID a second time should throw an Exception" 25 | (is (thrown-with-msg? 26 | clojure.lang.ExceptionInfo 27 | #"Invalid request ID" 28 | (state/accept-response! m 2)))) 29 | (testing "Add a few more request IDs" 30 | (state/record-request! m 3) 31 | (state/record-request! m 4) 32 | (is (= [[(t/instant "2020-09-25T08:00:00Z") #{1 3 4}]] 33 | @m))))) 34 | (testing "Move forward to t+3 minutes" 35 | (t/with-clock (t/mock-clock (t/instant "2020-09-25T08:03:00.000Z")) 36 | (testing "Add an ID. Buckets should get rotated" 37 | (state/record-request! m 5) 38 | (is (= [[(t/instant "2020-09-25T08:03:00.000Z") #{5}] 39 | [(t/instant "2020-09-25T08:00:00Z") #{1 3 4}] 40 | nil] 41 | @m))) 42 | (testing "Should be able to consume ID in other bucket" 43 | (state/accept-response! m 3) 44 | (is (= [[(t/instant "2020-09-25T08:03:00.000Z") #{5}] 45 | [(t/instant "2020-09-25T08:00:00Z") #{1 4}] 46 | nil] 47 | @m))))) 48 | (testing "Move forward to t+6 minutes" 49 | (t/with-clock (t/mock-clock (t/instant "2020-09-25T08:06:00.000Z")) 50 | (testing "Consume an ID. Buckets should get rotated" 51 | (state/accept-response! m 1) 52 | (is (= [[(t/instant "2020-09-25T08:06:00.000Z") #{}] 53 | [(t/instant "2020-09-25T08:03:00.000Z") #{5}] 54 | [(t/instant "2020-09-25T08:00:00Z") #{4}]] 55 | @m))) 56 | (testing "Add some more IDs" 57 | (state/record-request! m 6) 58 | (state/record-request! m 7) 59 | (is (= [[(t/instant "2020-09-25T08:06:00.000Z") #{6 7}] 60 | [(t/instant "2020-09-25T08:03:00.000Z") #{5}] 61 | [(t/instant "2020-09-25T08:00:00Z") #{4}]] 62 | @m))))) 63 | (testing "Move forward to t+9 minutes" 64 | (t/with-clock (t/mock-clock (t/instant "2020-09-25T08:09:00.000Z")) 65 | (testing "Attempt to consume now-ancient ID" 66 | (is (thrown-with-msg? 67 | clojure.lang.ExceptionInfo 68 | #"Invalid request ID" 69 | (state/accept-response! m 4)))) 70 | (testing "(buckets won't have been rotated because an Exception was thrown)" 71 | (is (= [[(t/instant "2020-09-25T08:06:00.000Z") #{6 7}] 72 | [(t/instant "2020-09-25T08:03:00.000Z") #{5}] 73 | [(t/instant "2020-09-25T08:00:00Z") #{4}]] 74 | @m))) 75 | (testing "adding a new ID will cause the old bucket to get dropped" 76 | (state/record-request! m 8) 77 | (is (= [[(t/instant "2020-09-25T08:09:00.000Z") #{8}] 78 | [(t/instant "2020-09-25T08:06:00.000Z") #{6 7}] 79 | [(t/instant "2020-09-25T08:03:00.000Z") #{5}]] 80 | @m))))))) 81 | 82 | (deftest e2e-test 83 | (let [m (state/in-memory-state-manager)] 84 | (t/with-clock (t/mock-clock (t/instant "2020-09-25T08:00:00.000Z")) 85 | (testing "generate request" 86 | (request/idp-redirect-response 87 | {:request-id "ABC" 88 | :sp-name "SP test" 89 | :acs-url "http://sp.example.com/demo1/index.php?acs" 90 | :idp-url "http://idp.example.com/SSOService.php" 91 | :issuer "http://sp.example.com/demo1/metadata.php" 92 | :state-manager m})) 93 | (testing "ID should be recorded" 94 | (is (= [[(t/instant "2020-09-25T08:00:00Z") #{"ABC"}]] 95 | @m))) 96 | (testing "Handle response" 97 | (letfn [(handle-response! [] 98 | (-> (str "