├── .github └── workflows │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── README.md ├── deps.edn ├── project.clj ├── src └── ring │ └── middleware │ └── oauth2.clj └── test └── ring └── middleware └── oauth2_test.clj /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v3 9 | 10 | - name: Prepare java 11 | uses: actions/setup-java@v3 12 | with: 13 | distribution: 'zulu' 14 | java-version: '8' 15 | 16 | - name: Install clojure tools 17 | uses: DeLaGuardo/setup-clojure@10.1 18 | with: 19 | lein: 2.9.10 20 | 21 | - name: Cache clojure dependencies 22 | uses: actions/cache@v3 23 | with: 24 | path: ~/.m2/repository 25 | key: cljdeps-${{ hashFiles('project.clj') }} 26 | restore-keys: cljdeps- 27 | 28 | - name: Run tests 29 | run: lein test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | /.cpcache 11 | .hgignore 12 | .hg/ 13 | /.clj-kondo 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | **Do** follow [the seven rules of a great Git commit message][1]. 4 | 5 | **Do** follow [the Clojure Style Guide][2]. 6 | 7 | **Do** include tests for your change when appropriate. 8 | 9 | **Do** ensure that the CI checks pass. 10 | 11 | **Do** squash the commits in your PR to remove corrections 12 | irrelevant to the code history, once the PR has been reviewed. 13 | 14 | **Do** feel free to pester the project maintainers about the PR if it 15 | hasn't been responded to. Sometimes notifications can be missed. 16 | 17 | **Don't** overuse vertical whitespace; avoid multiple sequential blank 18 | lines. 19 | 20 | **Don't** include more than one feature or fix in a single PR. 21 | 22 | **Don't** include changes unrelated to the purpose of the PR. This 23 | includes changing the project version number, adding lines to the 24 | `.gitignore` file, or changing the indentation or formatting. 25 | 26 | **Don't** open a new PR if changes are requested. Just push to the 27 | same branch and the PR will be updated. 28 | 29 | [1]: https://chris.beams.io/posts/git-commit/#seven-rules 30 | [2]: https://github.com/bbatsov/clojure-style-guide 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ring-OAuth2 [![Build Status](https://github.com/weavejester/ring-oauth2/actions/workflows/test.yml/badge.svg)](https://github.com/weavejester/ring-oauth2/actions/workflows/test.yml) 2 | 3 | [Ring][] middleware that acts as a [OAuth 2.0][] client. This is used 4 | for authenticating and integrating with third party website, like 5 | Twitter, Facebook and GitHub. 6 | 7 | [ring]: https://github.com/ring-clojure/ring 8 | [oauth 2.0]: https://oauth.net/2/ 9 | 10 | ## Installation 11 | 12 | To install, add the following to your deps.edn file: 13 | 14 | ring-oauth2/ring-oauth2 {:mvn/version "0.3.0"} 15 | 16 | Or to your Leiningen project file: 17 | 18 | [ring-oauth2 "0.3.0"] 19 | 20 | ## Usage 21 | 22 | The middleware function to use is `ring.middleware.oauth2/wrap-oauth2`. 23 | This takes a Ring handler, and a map of profiles as arguments. Each 24 | profile has a key to identify it, and a map of options that define how 25 | to authorize against a third-party service. 26 | 27 | Here's an example that provides authentication with GitHub: 28 | 29 | ```clojure 30 | (require '[ring.middleware.oauth2 :refer [wrap-oauth2]]) 31 | 32 | (def handler 33 | (wrap-oauth2 34 | routes 35 | {:github 36 | {:authorize-uri "https://github.com/login/oauth/authorize" 37 | :access-token-uri "https://github.com/login/oauth/access_token" 38 | :client-id "abcabcabc" 39 | :client-secret "xyzxyzxyzxyzxyz" 40 | :scopes ["user:email"] 41 | :launch-uri "/oauth2/github" 42 | :redirect-uri "/oauth2/github/callback" 43 | :landing-uri "/"}})) 44 | ``` 45 | 46 | The profile has a lot of options, and all have a necessary 47 | function. Let's go through them one by one. 48 | 49 | The first two keys are the authorize and access token URIs: 50 | 51 | * `:authorize-uri` 52 | * `:access-token-uri` 53 | 54 | These are URLs provided by the third-party website. If you look at the 55 | OAuth documentation for the site you're authenticating against, it 56 | should tell you which URLs to use. 57 | 58 | Next is the client ID and secret: 59 | 60 | * `:client-id` 61 | * `:client-secret` 62 | 63 | When you register your application with the third-party website, these 64 | two values should be provided to you. Note that these should not be 65 | kept in source control, especially the client secret! 66 | 67 | Optionally you can define the scope or scopes of the access you want: 68 | 69 | * `:scopes` 70 | 71 | These are used to ask the third-party website to provide access to 72 | certain information. In the previous example, we set the scopes to 73 | `["user:email"]`; in other words, we want to be able to access the 74 | user's email address. Scopes are a vector of either strings or 75 | keywords, and are specific to the website you're authenticating 76 | against. 77 | 78 | The next URIs are internal to your application and may be any URI you 79 | wish that your server can respond to: 80 | 81 | * `:launch-uri` 82 | * `:redirect-uri` 83 | * `:landing-uri` 84 | 85 | The launch URI kicks off the authorization process. Your log-in link 86 | should point to this address, and it should be unique per profile. 87 | 88 | The redirect URI provides the internal callback. It can be any 89 | relative URI as long as it is unique. It can also be an absolute URI like 90 | `https://loadbalanced-url.com/oauth2/github/callback` 91 | 92 | The landing URI is where the middleware redirects the user when the 93 | authentication process is complete. This could just be back to the 94 | index page, or it could be to the user's account page. Or you can use 95 | the optional `:redirect-handler` key, which expects a Ring handler 96 | function. When `:redirect-handler` is configured, `:landing-uri` will 97 | be ignored. 98 | 99 | * `:basic-auth?` 100 | 101 | This is an optional parameter, which defaults to false. 102 | If set to true, it includes the client-id and secret as a header 103 | `Authorization: Basic base64(id:secret)` as recommended by [the specification][]. 104 | 105 | Please note, you should enable cookies to be sent with cross-site requests, 106 | in order to make the callback request handling work correctly, eg: 107 | ```clojure 108 | (wrap-defaults handler (-> site-defaults (assoc-in [:session :cookie-attrs :same-site] :lax))) 109 | ``` 110 | 111 | Also, you must make sure that `ring.middleware.params/wrap-params` is 112 | enabled and runs before this middleware, as this library depends on the 113 | `:query-params` key to be present in the request. 114 | 115 | Once the middleware is set up, navigating to the `:launch-uri` will 116 | kick off the authorization process. If it succeeds, then the user will 117 | be directed to the `:landing-uri`. Once the user is authenticated, a 118 | new key is added to every request: 119 | 120 | * `:oauth2/access-tokens` 121 | 122 | This key contains a map that connects the profile keyword to it's 123 | corresponding access token. Using the earlier example of `:github` 124 | profile, the way you'd access the token would be as follows: 125 | 126 | ```clojure 127 | (-> request :oauth2/access-tokens :github) 128 | ``` 129 | 130 | The handler associated with the landing route can check for this token 131 | and complete authentication of the user. 132 | 133 | [the specification]: https://tools.ietf.org/html/rfc6749#section-2.3.1 134 | 135 | ### PKCE 136 | 137 | Some OAuth providers require an additional step called *Proof Key for 138 | Code Exchange* ([PKCE][]). Ring-OAuth2 will include a proof key in the 139 | workflow when `:pkce?` is set to `true`. 140 | 141 | [pkce]: https://www.oauth.com/oauth2-servers/pkce/authorization-request/ 142 | 143 | ## Workflow diagram 144 | 145 | The following image is a workflow diagram that describes the OAuth2 146 | authorization process for Ring-OAuth2. It should give you an overview 147 | of how all the different URIs interact: 148 | 149 | ```mermaid 150 | sequenceDiagram 151 | Client->>Ring Server: GET :launch-uri 152 | Ring Server-->>Client: redirect to :authorize-uri 153 | Client->>Auth Server: GET :authorize-uri 154 | Auth Server-->>Client: redirect to :redirect-uri 155 | Client->>Ring Server: GET :redirect-uri 156 | Ring Server->>Auth Server: POST :access-token-uri 157 | Auth Server->>Ring Server: returns access token 158 | Ring Server-->>Client: redirect to :landing-uri 159 | Client->>Ring Server: GET :landing-uri 160 | ``` 161 | 162 | ## Contributing 163 | 164 | Please see [CONTRIBUTING.md][1]. 165 | 166 | [1]: https://github.com/weavejester/ring-oauth2/blob/master/CONTRIBUTING.md 167 | 168 | ## License 169 | 170 | Copyright © 2024 James Reeves 171 | 172 | Released under the MIT License. 173 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.9.0"} 2 | cheshire/cheshire {:mvn/version "5.13.0"} 3 | clj-http/clj-http {:mvn/version "3.13.0"} 4 | ring/ring-core {:mvn/version "1.12.2"}}} 5 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject ring-oauth2 "0.3.0" 2 | :description "OAuth 2.0 client middleware for Ring" 3 | :url "https://github.com/weavejester/ring-oauth2" 4 | :license {:name "The MIT License" 5 | :url "http://opensource.org/licenses/MIT"} 6 | :dependencies [[org.clojure/clojure "1.9.0"] 7 | [cheshire "5.13.0"] 8 | [clj-http "3.13.0"] 9 | [ring/ring-core "1.12.2"]] 10 | :profiles 11 | {:dev {:dependencies [[clj-http-fake "1.0.4"] 12 | [ring/ring-mock "0.4.0"]]}}) 13 | -------------------------------------------------------------------------------- /src/ring/middleware/oauth2.clj: -------------------------------------------------------------------------------- 1 | (ns ring.middleware.oauth2 2 | (:require [clj-http.client :as http] 3 | [clojure.string :as str] 4 | [crypto.random :as random] 5 | [ring.util.codec :as codec] 6 | [ring.util.request :as req] 7 | [ring.util.response :as resp]) 8 | (:import [java.time Instant] 9 | [java.util Date] 10 | [java.security MessageDigest] 11 | [java.nio.charset StandardCharsets] 12 | [org.apache.commons.codec.binary Base64])) 13 | 14 | (defn- redirect-uri [profile request] 15 | (-> (req/request-url request) 16 | (java.net.URI/create) 17 | (.resolve (:redirect-uri profile)) 18 | str)) 19 | 20 | (defn- scopes [profile] 21 | (str/join " " (map name (:scopes profile)))) 22 | 23 | (defn- base64 [^bytes bs] 24 | (String. (Base64/encodeBase64 bs))) 25 | 26 | (defn- str->sha256 [^String s] 27 | (-> (MessageDigest/getInstance "SHA-256") 28 | (.digest (.getBytes s StandardCharsets/UTF_8)))) 29 | 30 | (defn- base64url [base64-str] 31 | (-> base64-str (str/replace "+" "-") (str/replace "/" "_"))) 32 | 33 | (defn- verifier->challenge [^String verifier] 34 | (-> verifier str->sha256 base64 base64url (str/replace "=" ""))) 35 | 36 | (defn- authorize-params [profile request state verifier] 37 | (-> {:response_type "code" 38 | :client_id (:client-id profile) 39 | :redirect_uri (redirect-uri profile request) 40 | :scope (scopes profile) 41 | :state state} 42 | (cond-> (:pkce? profile) 43 | (assoc :code_challenge (verifier->challenge verifier) 44 | :code_challenge_method "S256")))) 45 | 46 | (defn- authorize-uri [profile request state verifier] 47 | (str (:authorize-uri profile) 48 | (if (.contains ^String (:authorize-uri profile) "?") "&" "?") 49 | (codec/form-encode (authorize-params profile request state verifier)))) 50 | 51 | (defn- random-state [] 52 | (base64url (random/base64 9))) 53 | 54 | (defn- random-code-verifier [] 55 | (base64url (random/base64 63))) 56 | 57 | (defn- make-launch-handler [{:keys [pkce?] :as profile}] 58 | (fn handler 59 | ([{:keys [session] :or {session {}} :as request}] 60 | (let [state (random-state) 61 | verifier (when pkce? (random-code-verifier)) 62 | session' (-> session 63 | (assoc ::state state) 64 | (cond-> pkce? (assoc ::code-verifier verifier)))] 65 | (-> (resp/redirect (authorize-uri profile request state verifier)) 66 | (assoc :session session')))) 67 | ([request respond raise] 68 | (when-let [response (try (handler request) 69 | (catch Exception e (raise e) false))] 70 | (respond response))))) 71 | 72 | (defn- state-matches? [request] 73 | (= (get-in request [:session ::state]) 74 | (get-in request [:query-params "state"]))) 75 | 76 | (defn- coerce-to-int [n] 77 | (if (string? n) 78 | (Integer/parseInt n) 79 | n)) 80 | 81 | (defn- seconds-from-now-to-date [secs] 82 | (-> (Instant/now) (.plusSeconds secs) (Date/from))) 83 | 84 | (defn- format-access-token 85 | [{{:keys [access_token expires_in refresh_token id_token] :as body} :body}] 86 | (-> {:token access_token 87 | :extra-data (dissoc body 88 | :access_token :expires_in 89 | :refresh_token :id_token)} 90 | (cond-> expires_in (assoc :expires (-> (coerce-to-int expires_in) 91 | (seconds-from-now-to-date))) 92 | refresh_token (assoc :refresh-token refresh_token) 93 | id_token (assoc :id-token id_token)))) 94 | 95 | (defn- get-authorization-code [request] 96 | (get-in request [:query-params "code"])) 97 | 98 | (defn- get-code-verifier [request] 99 | (get-in request [:session ::code-verifier])) 100 | 101 | (defn- request-params [{:keys [pkce?] :as profile} request] 102 | (-> {:grant_type "authorization_code" 103 | :code (get-authorization-code request) 104 | :redirect_uri (redirect-uri profile request)} 105 | (cond-> pkce? (assoc :code_verifier (get-code-verifier request))))) 106 | 107 | (defn- add-header-credentials [opts id secret] 108 | (assoc opts :basic-auth [id secret])) 109 | 110 | (defn- add-form-credentials [opts id secret] 111 | (assoc opts :form-params (-> (:form-params opts) 112 | (merge {:client_id id 113 | :client_secret secret})))) 114 | 115 | (defn- access-token-http-options 116 | [{:keys [access-token-uri client-id client-secret basic-auth?] 117 | :or {basic-auth? false} :as profile} 118 | request] 119 | (let [opts {:method :post 120 | :url access-token-uri 121 | :accept :json 122 | :as :json 123 | :form-params (request-params profile request)}] 124 | (if basic-auth? 125 | (add-header-credentials opts client-id client-secret) 126 | (add-form-credentials opts client-id client-secret)))) 127 | 128 | (defn- get-access-token 129 | ([profile request] 130 | (-> (http/request (access-token-http-options profile request)) 131 | (format-access-token))) 132 | ([profile request respond raise] 133 | (http/request (-> (access-token-http-options profile request) 134 | (assoc :async? true)) 135 | (comp respond format-access-token) 136 | raise))) 137 | 138 | (defn state-mismatch-handler 139 | ([_] 140 | {:status 400 141 | :headers {"Content-Type" "text/plain; charset=utf-8"} 142 | :body "OAuth2 error: state mismatch"}) 143 | ([request respond _] 144 | (respond (state-mismatch-handler request)))) 145 | 146 | (defn no-auth-code-handler 147 | ([_] 148 | {:status 400 149 | :headers {"Content-Type" "text/plain; charset=utf-8"} 150 | :body "OAuth2 error: no authorization code"}) 151 | ([request respond _] 152 | (respond (no-auth-code-handler request)))) 153 | 154 | (defn- redirect-response [{:keys [id landing-uri]} session access-token] 155 | (-> (resp/redirect landing-uri) 156 | (assoc :session (-> session 157 | (assoc-in [::access-tokens id] access-token) 158 | (dissoc ::state ::code-verifier))))) 159 | 160 | (defn- make-redirect-handler 161 | [{:keys [state-mismatch-handler no-auth-code-handler] 162 | :or {state-mismatch-handler state-mismatch-handler 163 | no-auth-code-handler no-auth-code-handler} 164 | :as profile}] 165 | (fn 166 | ([{:keys [session] :or {session {}} :as request}] 167 | (cond 168 | (not (state-matches? request)) 169 | (state-mismatch-handler request) 170 | 171 | (nil? (get-authorization-code request)) 172 | (no-auth-code-handler request) 173 | 174 | :else 175 | (let [access-token (get-access-token profile request)] 176 | (redirect-response profile session access-token)))) 177 | ([{:keys [session] :or {session {}} :as request} respond raise] 178 | (cond 179 | (not (state-matches? request)) 180 | (state-mismatch-handler request respond raise) 181 | 182 | (nil? (get-authorization-code request)) 183 | (no-auth-code-handler request respond raise) 184 | 185 | :else 186 | (get-access-token profile request 187 | (fn [token] 188 | (respond (redirect-response profile session token))) 189 | raise))))) 190 | 191 | (defn- assoc-access-tokens [request] 192 | (if-let [tokens (-> request :session ::access-tokens)] 193 | (assoc request :oauth2/access-tokens tokens) 194 | request)) 195 | 196 | (defn- parse-redirect-url [{:keys [redirect-uri]}] 197 | (.getPath (java.net.URI. redirect-uri))) 198 | 199 | (defn- valid-profile? [{:keys [client-id client-secret]}] 200 | (and (some? client-id) (some? client-secret))) 201 | 202 | (defn wrap-oauth2 [handler profiles] 203 | {:pre [(every? valid-profile? (vals profiles))]} 204 | (let [profiles (for [[k v] profiles] (assoc v :id k)) 205 | launches (into {} (map (juxt :launch-uri identity)) profiles) 206 | redirects (into {} (map (juxt parse-redirect-url identity)) profiles)] 207 | (fn 208 | ([{:keys [uri] :as request}] 209 | (if-let [profile (launches uri)] 210 | ((make-launch-handler profile) request) 211 | (if-let [profile (redirects uri)] 212 | ((:redirect-handler profile (make-redirect-handler profile)) request) 213 | (handler (assoc-access-tokens request))))) 214 | ([{:keys [uri] :as request} respond raise] 215 | (if-let [profile (launches uri)] 216 | ((make-launch-handler profile) request respond raise) 217 | (if-let [profile (redirects uri)] 218 | ((:redirect-handler profile (make-redirect-handler profile)) 219 | request respond raise) 220 | (handler (assoc-access-tokens request) respond raise))))))) 221 | -------------------------------------------------------------------------------- /test/ring/middleware/oauth2_test.clj: -------------------------------------------------------------------------------- 1 | (ns ring.middleware.oauth2-test 2 | (:require [clj-http.fake :as fake] 3 | [clojure.string :as str] 4 | [clojure.test :refer [deftest is testing]] 5 | [cheshire.core :as cheshire] 6 | [ring.middleware.oauth2 :as oauth2 :refer [wrap-oauth2]] 7 | [ring.mock.request :as mock] 8 | [ring.middleware.params :refer [wrap-params]] 9 | [ring.util.codec :as codec]) 10 | (:import [java.time Instant] 11 | [java.util Date])) 12 | 13 | (def test-profile 14 | {:authorize-uri "https://example.com/oauth2/authorize" 15 | :access-token-uri "https://example.com/oauth2/access-token" 16 | :redirect-uri "/oauth2/test/callback" 17 | :launch-uri "/oauth2/test" 18 | :landing-uri "/" 19 | :scopes [:user :project] 20 | :client-id "abcdef" 21 | :client-secret "01234567890abcdef"}) 22 | 23 | (def test-profile-pkce 24 | (assoc test-profile :pkce? true)) 25 | 26 | (defn- token-handler 27 | ([{:keys [oauth2/access-tokens]}] 28 | {:status 200, :headers {}, :body access-tokens}) 29 | ([request respond _raise] 30 | (respond (token-handler request)))) 31 | 32 | (def test-handler 33 | (wrap-oauth2 token-handler {:test test-profile})) 34 | 35 | (def test-handler-pkce 36 | (wrap-oauth2 token-handler {:test test-profile-pkce})) 37 | 38 | (deftest test-launch-uri 39 | (testing "sync handlers" 40 | (let [response (test-handler (mock/request :get "/oauth2/test")) 41 | location (get-in response [:headers "Location"]) 42 | [_ query] (str/split location #"\?" 2) 43 | params (codec/form-decode query)] 44 | (is (= 302 (:status response))) 45 | (is (.startsWith ^String location "https://example.com/oauth2/authorize?")) 46 | (is (= {"response_type" "code" 47 | "client_id" "abcdef" 48 | "redirect_uri" "http://localhost/oauth2/test/callback" 49 | "scope" "user project"} 50 | (dissoc params "state"))) 51 | (is (re-matches #"[A-Za-z0-9_-]{12}" (params "state"))) 52 | (is (= {::oauth2/state (params "state")} 53 | (:session response))))) 54 | 55 | (testing "async handlers" 56 | (let [respond (promise) 57 | raise (promise)] 58 | (test-handler (mock/request :get "/oauth2/test") respond raise) 59 | (let [response (deref respond 100 :empty) 60 | error (deref raise 100 :empty)] 61 | (is (not= response :empty)) 62 | (is (= error :empty)) 63 | (let [location (get-in response [:headers "Location"]) 64 | [_ query] (str/split location #"\?" 2) 65 | params (codec/form-decode query)] 66 | (is (= 302 (:status response))) 67 | (is (.startsWith ^String location "https://example.com/oauth2/authorize?")) 68 | (is (= {"response_type" "code" 69 | "client_id" "abcdef" 70 | "redirect_uri" "http://localhost/oauth2/test/callback" 71 | "scope" "user project"} 72 | (dissoc params "state"))) 73 | (is (re-matches #"[A-Za-z0-9_-]{12}" (params "state"))) 74 | (is (= {::oauth2/state (params "state")} 75 | (:session response)))))))) 76 | 77 | (deftest test-launch-uri-pkce 78 | (let [response (test-handler-pkce (mock/request :get "/oauth2/test")) 79 | location (get-in response [:headers "Location"]) 80 | [_ query] (str/split location #"\?" 2) 81 | params (codec/form-decode query)] 82 | (is (contains? params "code_challenge")) 83 | (is (= "S256" (get params "code_challenge_method"))))) 84 | 85 | (deftest test-missing-fields 86 | (let [profile (assoc test-profile :client-id nil)] 87 | (is (thrown? AssertionError (wrap-oauth2 token-handler {:test profile})))) 88 | 89 | (let [profile (assoc test-profile :client-secret nil)] 90 | (is (thrown? AssertionError (wrap-oauth2 token-handler {:test profile}))))) 91 | 92 | (deftest test-location-uri-with-query 93 | (let [profile (assoc test-profile 94 | :authorize-uri 95 | "https://example.com/oauth2/authorize?pid=XXXX") 96 | handler (wrap-oauth2 token-handler {:test profile}) 97 | response (handler (mock/request :get "/oauth2/test")) 98 | location (get-in response [:headers "Location"])] 99 | (is (.startsWith ^String location 100 | "https://example.com/oauth2/authorize?pid=XXXX&")))) 101 | 102 | (def token-response 103 | {:status 200 104 | :headers {"Content-Type" "application/json"} 105 | :body "{\"access_token\":\"defdef\",\"expires_in\":3600,\"foo\":\"bar\"}"}) 106 | 107 | (defn- approx-eq [a b] 108 | (let [a-ms (.getTime a) 109 | b-ms (.getTime b)] 110 | (< (- a-ms 1000) b-ms (+ a-ms 1000)))) 111 | 112 | (defn- seconds-from-now-to-date [secs] 113 | (-> (Instant/now) (.plusSeconds secs) (Date/from))) 114 | 115 | (deftest test-redirect-uri 116 | (fake/with-fake-routes 117 | {"https://example.com/oauth2/access-token" (constantly token-response)} 118 | 119 | (testing "valid state" 120 | (let [request (-> (mock/request :get "/oauth2/test/callback") 121 | (assoc :session {::oauth2/state "xyzxyz"}) 122 | (assoc :query-params {"code" "abcabc" 123 | "state" "xyzxyz"})) 124 | response (test-handler request) 125 | expires (seconds-from-now-to-date 3600)] 126 | (is (= 302 (:status response))) 127 | (is (= "/" (get-in response [:headers "Location"]))) 128 | (is (map? (-> response :session ::oauth2/access-tokens))) 129 | (is (= "defdef" 130 | (-> response :session ::oauth2/access-tokens :test :token))) 131 | (is (approx-eq expires 132 | (-> response 133 | :session ::oauth2/access-tokens :test :expires))) 134 | (is (= {:foo "bar"} 135 | (-> response 136 | :session ::oauth2/access-tokens :test :extra-data))))) 137 | 138 | (testing "invalid state" 139 | (let [request (-> (mock/request :get "/oauth2/test/callback") 140 | (assoc :session {::oauth2/state "xyzxyz"}) 141 | (assoc :query-params {"code" "abcabc" 142 | "state" "xyzxya"})) 143 | response (test-handler request)] 144 | (is (= {:status 400 145 | :headers {"Content-Type" "text/plain; charset=utf-8"} 146 | :body "OAuth2 error: state mismatch"} 147 | response)))) 148 | 149 | (testing "custom state mismatched error" 150 | (let [error {:status 400, :headers {}, :body "Error!"} 151 | profile (assoc test-profile 152 | :state-mismatch-handler (constantly error)) 153 | handler (wrap-oauth2 token-handler {:test profile}) 154 | request (-> (mock/request :get "/oauth2/test/callback") 155 | (assoc :session {::oauth2/state "xyzxyz"}) 156 | (assoc :query-params {"code" "abcabc" 157 | "state" "xyzxya"})) 158 | response (handler request)] 159 | (is (= {:status 400, :headers {}, :body "Error!"} 160 | response)))) 161 | 162 | (testing "no authorization code" 163 | (let [request (-> (mock/request :get "/oauth2/test/callback") 164 | (assoc :session {::oauth2/state "xyzxyz"}) 165 | (assoc :query-params {"state" "xyzxyz"})) 166 | response (test-handler request)] 167 | (is (= {:status 400 168 | :headers {"Content-Type" "text/plain; charset=utf-8"} 169 | :body "OAuth2 error: no authorization code"} 170 | response)))) 171 | 172 | (testing "custom no authorization code error" 173 | (let [error {:status 400, :headers {}, :body "Error!"} 174 | profile (assoc test-profile 175 | :no-auth-code-handler (constantly error)) 176 | handler (wrap-oauth2 token-handler {:test profile}) 177 | request (-> (mock/request :get "/oauth2/test/callback") 178 | (assoc :session {::oauth2/state "xyzxyz"}) 179 | (assoc :query-params {"state" "xyzxyz"})) 180 | response (handler request)] 181 | (is (= {:status 400, :headers {}, :body "Error!"} 182 | response)))) 183 | 184 | (testing "absolute redirect uri" 185 | (let [profile (assoc test-profile 186 | :redirect-uri 187 | "https://example.com/oauth2/test/callback?query") 188 | handler (wrap-oauth2 token-handler {:test profile}) 189 | request (-> (mock/request :get "/oauth2/test/callback") 190 | (assoc :session {::oauth2/state "xyzxyz"}) 191 | (assoc :query-params {"code" "abcabc" 192 | "state" "xyzxyz"})) 193 | response (handler request) 194 | expires (seconds-from-now-to-date 3600)] 195 | (is (= 302 (:status response))) 196 | (is (= "/" (get-in response [:headers "Location"]))) 197 | (is (map? (-> response :session ::oauth2/access-tokens))) 198 | (is (= "defdef" 199 | (-> response :session ::oauth2/access-tokens :test :token))) 200 | (is (approx-eq expires 201 | (-> response 202 | :session ::oauth2/access-tokens :test :expires))))))) 203 | 204 | (deftest test-access-tokens-key 205 | (let [tokens {:test {:token "defdef", :expires 3600}}] 206 | (is (= {:status 200, :headers {}, :body tokens} 207 | (-> (mock/request :get "/") 208 | (assoc :session {::oauth2/access-tokens tokens}) 209 | (test-handler)))))) 210 | 211 | (deftest test-true-basic-auth-param 212 | (fake/with-fake-routes 213 | {"https://example.com/oauth2/access-token" 214 | (fn [req] 215 | (let [auth (get-in req [:headers "authorization"])] 216 | (is (and (not (str/blank? auth)) 217 | (.startsWith auth "Basic"))) 218 | token-response))} 219 | 220 | (testing "valid state" 221 | (let [profile (assoc test-profile :basic-auth? true) 222 | handler (wrap-oauth2 token-handler {:test profile}) 223 | request (-> (mock/request :get "/oauth2/test/callback") 224 | (assoc :session {::oauth2/state "xyzxyz"}) 225 | (assoc :query-params {"code" "abcabc" 226 | "state" "xyzxyz"})) 227 | response (handler request)] 228 | (is (= 302 (:status response))))))) 229 | 230 | (defn- contains-many? [m & ks] 231 | (every? #(contains? m %) ks)) 232 | 233 | (deftest test-false-basic-auth-param 234 | (fake/with-fake-routes 235 | {"https://example.com/oauth2/access-token" 236 | (wrap-params (fn [req] 237 | (let [params (get-in req [:params])] 238 | (is (contains-many? params "client_id" "client_secret")) 239 | token-response)))} 240 | 241 | (testing "valid state" 242 | (let [profile (assoc test-profile :basic-auth? false) 243 | handler (wrap-oauth2 token-handler {:test profile}) 244 | request (-> (mock/request :get "/oauth2/test/callback") 245 | (assoc :session {::oauth2/state "xyzxyz"}) 246 | (assoc :query-params {"code" "abcabc" 247 | "state" "xyzxyz"})) 248 | response (handler request)] 249 | (is (= 302 (:status response))))))) 250 | 251 | (def openid-response 252 | {:status 200 253 | :headers {"Content-Type" "application/json"} 254 | :body "{\"access_token\":\"defdef\",\"expires_in\":3600, 255 | \"refresh_token\":\"ghighi\",\"id_token\":\"abc.def.ghi\"}"}) 256 | 257 | (deftest test-openid-response 258 | (fake/with-fake-routes 259 | {"https://example.com/oauth2/access-token" (constantly openid-response)} 260 | 261 | (testing "valid state" 262 | (let [request (-> (mock/request :get "/oauth2/test/callback") 263 | (assoc :session {::oauth2/state "xyzxyz"}) 264 | (assoc :query-params {"code" "abcabc" 265 | "state" "xyzxyz"})) 266 | response (test-handler request) 267 | expires (seconds-from-now-to-date 3600)] 268 | (is (= 302 (:status response))) 269 | (is (= "/" (get-in response [:headers "Location"]))) 270 | (is (map? (-> response :session ::oauth2/access-tokens))) 271 | (is (= "defdef" 272 | (-> response :session ::oauth2/access-tokens :test :token))) 273 | (is (= "ghighi" 274 | (-> response 275 | :session ::oauth2/access-tokens :test :refresh-token))) 276 | (is (= "abc.def.ghi" 277 | (-> response 278 | :session ::oauth2/access-tokens :test :id-token))) 279 | (is (approx-eq expires 280 | (-> response 281 | :session ::oauth2/access-tokens :test :expires))))) 282 | 283 | (testing "async handler" 284 | (let [request (-> (mock/request :get "/oauth2/test/callback") 285 | (assoc :session {::oauth2/state "xyzxyz"}) 286 | (assoc :query-params {"code" "abcabc" 287 | "state" "xyzxyz"})) 288 | respond (promise) 289 | raise (promise) 290 | expires (seconds-from-now-to-date 3600)] 291 | (test-handler request respond raise) 292 | (let [response (deref respond 100 :empty) 293 | error (deref raise 100 :empty)] 294 | (is (not= response :empty) "timeout getting response") 295 | (is (= error :empty)) 296 | (is (= 302 (:status response))) 297 | (is (= "/" (get-in response [:headers "Location"]))) 298 | (is (map? (-> response :session ::oauth2/access-tokens))) 299 | (is (= "defdef" 300 | (-> response :session ::oauth2/access-tokens :test :token))) 301 | (is (= "ghighi" 302 | (-> response 303 | :session ::oauth2/access-tokens :test :refresh-token))) 304 | (is (= "abc.def.ghi" 305 | (-> response 306 | :session ::oauth2/access-tokens :test :id-token))) 307 | (is (approx-eq expires 308 | (-> response 309 | :session ::oauth2/access-tokens :test :expires)))))))) 310 | 311 | (def openid-response-with-string-expires 312 | {:status 200 313 | :headers {"Content-Type" "application/json"} 314 | :body "{\"access_token\":\"defdef\",\"expires_in\": \"3600\", 315 | \"refresh_token\":\"ghighi\",\"id_token\":\"abc.def.ghi\"}"}) 316 | 317 | (deftest test-openid-response-with-string-expires 318 | (fake/with-fake-routes 319 | {"https://example.com/oauth2/access-token" 320 | (constantly openid-response-with-string-expires)} 321 | 322 | (testing "valid state" 323 | (let [request (-> (mock/request :get "/oauth2/test/callback") 324 | (assoc :session {::oauth2/state "xyzxyz"}) 325 | (assoc :query-params {"code" "abcabc" 326 | "state" "xyzxyz"})) 327 | response (test-handler request) 328 | expires (seconds-from-now-to-date 3600)] 329 | (is (= 302 (:status response))) 330 | (is (= "/" (get-in response [:headers "Location"]))) 331 | (is (approx-eq expires 332 | (-> response 333 | :session ::oauth2/access-tokens :test :expires))))))) 334 | 335 | (defn openid-response-with-code-verifier [req] 336 | {:status 200 337 | :headers {"Content-Type" "application/json"} 338 | :body (cheshire/generate-string 339 | {:access_token "defdef" 340 | :expires_in 3600 341 | :refresh_token "ghighi" 342 | :id_token "abc.def.ghi" 343 | :code_verifier (-> req :body slurp codec/form-decode 344 | (get "code_verifier"))})}) 345 | 346 | (deftest test-openid-response-with-code-verifier 347 | (fake/with-fake-routes 348 | {"https://example.com/oauth2/access-token" 349 | openid-response-with-code-verifier} 350 | 351 | (testing "verifier in extra data" 352 | (let [request (-> (mock/request :get "/oauth2/test/callback") 353 | (assoc :session {::oauth2/state "xyzxyz" 354 | ::oauth2/code-verifier "jkljkl"}) 355 | (assoc :query-params {"code" "abcabc" 356 | "state" "xyzxyz"})) 357 | response (test-handler-pkce request)] 358 | (is (= "jkljkl" 359 | (-> response 360 | :session ::oauth2/access-tokens :test 361 | :extra-data :code_verifier))))))) 362 | 363 | (defn- redirect-handler [_] 364 | {:status 200, :headers {}, :body "redirect-handler-response-body"}) 365 | 366 | (deftest test-redirect-handler 367 | (let [profile (assoc test-profile 368 | :redirect-handler redirect-handler) 369 | handler (wrap-oauth2 token-handler {:test profile}) 370 | request (-> (mock/request :get "/oauth2/test/callback") 371 | (assoc :session {::oauth2/state "xyzxyz"}) 372 | (assoc :query-params {"code" "abcabc", "state" "xyzxyz"})) 373 | response (handler request) 374 | body (:body response)] 375 | (is (= "redirect-handler-response-body" body)))) 376 | 377 | (deftest test-handler-passthrough 378 | (let [tokens {:test "tttkkkk"} 379 | request (-> (mock/request :get "/example") 380 | (assoc :session {::oauth2/access-tokens tokens}))] 381 | (testing "sync handler" 382 | (is (= {:status 200, :headers {}, :body tokens} 383 | (test-handler request)))) 384 | 385 | (testing "async handler" 386 | (let [respond (promise) 387 | raise (promise)] 388 | (test-handler request respond raise) 389 | (is (= :empty 390 | (deref raise 100 :empty))) 391 | (is (= {:status 200, :headers {}, :body tokens} 392 | (deref respond 100 :empty))))))) 393 | --------------------------------------------------------------------------------