Skip to content

Commit

Permalink
Merge pull request #51 from weavejester/pkce-support
Browse files Browse the repository at this point in the history
Add PKCE support
  • Loading branch information
weavejester committed Sep 6, 2023
2 parents c1db932 + 1aa529b commit 698f671
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 17 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ and complete authentication of the user.

[the specification]: https://tools.ietf.org/html/rfc6749#section-2.3.1

### PKCE

Some OAuth providers require an additional step called *Proof Key for
Code Exchange* ([PKCE][]). Ring-OAuth2 will include a proof key in the
workflow when `:pkce?` is set to `true`.

[pkce]: https://www.oauth.com/oauth2-servers/pkce/authorization-request/

## Workflow diagram

The following image is a workflow diagram that describes the OAuth2
Expand Down
67 changes: 50 additions & 17 deletions src/ring/middleware/oauth2.clj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
[ring.util.request :as req]
[ring.util.response :as resp])
(:import [java.time Instant]
[java.util Date]))
[java.util Date]
[java.security MessageDigest]
[java.nio.charset StandardCharsets]
[org.apache.commons.codec.binary Base64]))

(defn- redirect-uri [profile request]
(-> (req/request-url request)
Expand All @@ -17,23 +20,49 @@
(defn- scopes [profile]
(str/join " " (map name (:scopes profile))))

(defn- authorize-uri [profile request state]
(defn- base64 [^bytes bs]
(String. (Base64/encodeBase64 bs)))

(defn- str->sha256 [^String s]
(-> (MessageDigest/getInstance "SHA-256")
(.digest (.getBytes s StandardCharsets/UTF_8))))

(defn- base64url [base64-str]
(-> base64-str (str/replace "+" "-") (str/replace "/" "_")))

(defn- verifier->challenge [^String verifier]
(-> verifier str->sha256 base64 base64url (str/replace "=" "")))

(defn- authorize-params [profile request state verifier]
(-> {:response_type "code"
:client_id (:client-id profile)
:redirect_uri (redirect-uri profile request)
:scope (scopes profile)
:state state}
(cond-> (:pkce? profile)
(assoc :code_challenge (verifier->challenge verifier)
:code_challenge_method "S256"))))

(defn- authorize-uri [profile request state verifier]
(str (:authorize-uri profile)
(if (.contains ^String (:authorize-uri profile) "?") "&" "?")
(codec/form-encode {:response_type "code"
:client_id (:client-id profile)
:redirect_uri (redirect-uri profile request)
:scope (scopes profile)
:state state})))
(codec/form-encode (authorize-params profile request state verifier))))

(defn- random-state []
(-> (random/base64 9) (str/replace "+" "-") (str/replace "/" "_")))
(base64url (random/base64 9)))

(defn- random-code-verifier []
(base64url (random/base64 63)))

(defn- make-launch-handler [profile]
(defn- make-launch-handler [{:keys [pkce?] :as profile}]
(fn [{:keys [session] :or {session {}} :as request}]
(let [state (random-state)]
(-> (resp/redirect (authorize-uri profile request state))
(assoc :session (assoc session ::state state))))))
(let [state (random-state)
verifier (when pkce? (random-code-verifier))
session' (-> session
(assoc ::state state)
(cond-> pkce? (assoc ::code-verifier verifier)))]
(-> (resp/redirect (authorize-uri profile request state verifier))
(assoc :session session')))))

(defn- state-matches? [request]
(= (get-in request [:session ::state])
Expand Down Expand Up @@ -61,10 +90,14 @@
(defn- get-authorization-code [request]
(get-in request [:query-params "code"]))

(defn- request-params [profile request]
{:grant_type "authorization_code"
:code (get-authorization-code request)
:redirect_uri (redirect-uri profile request)})
(defn- get-code-verifier [request]
(get-in request [:session ::code-verifier]))

(defn- request-params [{:keys [pkce?] :as profile} request]
(-> {:grant_type "authorization_code"
:code (get-authorization-code request)
:redirect_uri (redirect-uri profile request)}
(cond-> pkce? (assoc :code_verifier (get-code-verifier request)))))

(defn- add-header-credentials [opts id secret]
(assoc opts :basic-auth [id secret]))
Expand Down Expand Up @@ -108,7 +141,7 @@
(-> (resp/redirect landing-uri)
(assoc :session (-> session
(assoc-in [::access-tokens id] access-token)
(dissoc ::state)))))))))
(dissoc ::state ::code-verifier)))))))))

(defn- assoc-access-tokens [request]
(if-let [tokens (-> request :session ::access-tokens)]
Expand Down
43 changes: 43 additions & 0 deletions test/ring/middleware/oauth2_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
(:require [clj-http.fake :as fake]
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[cheshire.core :as cheshire]
[ring.middleware.oauth2 :as oauth2 :refer [wrap-oauth2]]
[ring.mock.request :as mock]
[ring.middleware.params :refer [wrap-params]]
Expand All @@ -19,12 +20,18 @@
:client-id "abcdef"
:client-secret "01234567890abcdef"})

(def test-profile-pkce
(assoc test-profile :pkce? true))

(defn- token-handler [{:keys [oauth2/access-tokens]}]
{:status 200, :headers {}, :body access-tokens})

(def test-handler
(wrap-oauth2 token-handler {:test test-profile}))

(def test-handler-pkce
(wrap-oauth2 token-handler {:test test-profile-pkce}))

(deftest test-launch-uri
(let [response (test-handler (mock/request :get "/oauth2/test"))
location (get-in response [:headers "Location"])
Expand All @@ -41,6 +48,14 @@
(is (= {::oauth2/state (params "state")}
(:session response)))))

(deftest test-launch-uri-pkce
(let [response (test-handler-pkce (mock/request :get "/oauth2/test"))
location (get-in response [:headers "Location"])
[_ query] (str/split location #"\?" 2)
params (codec/form-decode query)]
(is (contains? params "code_challenge"))
(is (= "S256" (get params "code_challenge_method")))))

(deftest test-missing-fields
(let [profile (assoc test-profile :client-id nil)]
(is (thrown? AssertionError (wrap-oauth2 token-handler {:test profile}))))
Expand Down Expand Up @@ -259,6 +274,34 @@
(-> response
:session ::oauth2/access-tokens :test :expires)))))))

(defn openid-response-with-code-verifier [req]
{:status 200
:headers {"Content-Type" "application/json"}
:body (cheshire/generate-string
{:access_token "defdef"
:expires_in 3600
:refresh_token "ghighi"
:id_token "abc.def.ghi"
:code_verifier (-> req :body slurp codec/form-decode
(get "code_verifier"))})})

(deftest test-openid-response-with-code-verifier
(fake/with-fake-routes
{"https://example.com/oauth2/access-token"
openid-response-with-code-verifier}

(testing "verifier in extra data"
(let [request (-> (mock/request :get "/oauth2/test/callback")
(assoc :session {::oauth2/state "xyzxyz"
::oauth2/code-verifier "jkljkl"})
(assoc :query-params {"code" "abcabc"
"state" "xyzxyz"}))
response (test-handler-pkce request)]
(is (= "jkljkl"
(-> response
:session ::oauth2/access-tokens :test
:extra-data :code_verifier)))))))

(defn- redirect-handler [_]
{:status 200, :headers {}, :body "redirect-handler-response-body"})

Expand Down

0 comments on commit 698f671

Please sign in to comment.