Skip to content

A number of useful commits from the-kenny, plus compatibility with Google's OAuth and support for refresh tokens #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ pom.xml
/lib/
/classes/
.lein-deps-sum
/target
/.lein-failures
10 changes: 5 additions & 5 deletions project.clj
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
(defproject clj-oauth2 "0.3.0"
(defproject org.clojars.the-kenny/clj-oauth2 "0.3.1"
:min-lein-version "2.0.0"
:description "clj-http and ring middlewares for OAuth 2.0"
:dependencies [[org.clojure/clojure "1.3.0"]
[org.clojure/data.json "0.1.1"]
[clj-http "0.2.6"]
[uri "1.1.0"]
[commons-codec/commons-codec "1.6"]]
:exclusions [org.clojure/clojure-contrib]
:dev-dependencies [[ring "0.3.11"]
[com.stuartsierra/lazytest "1.1.2"
:exclusions [swank-clojure]]]
:profiles {:dev {:dependencies [[ring "0.3.11"]]}}
:repositories {"stuartsierra-releases" "http://stuartsierra.com/maven2"}
:aot [clj-oauth2.OAuth2Exception clj-oauth2.OAuth2StateMismatchException])
:aot [clj-oauth2.OAuth2Exception
clj-oauth2.OAuth2StateMismatchException])
26 changes: 19 additions & 7 deletions src/clj_oauth2/client.clj
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
[org.apache.commons.codec.binary Base64]))

(defn make-auth-request
[{:keys [authorization-uri client-id client-secret redirect-uri scope]}
[{:keys [authorization-uri client-id redirect-uri scope access-type]}
& [state]]
(let [uri (uri/uri->map (uri/make authorization-uri) true)
query (assoc (:query uri)
:client_id client-id
:redirect_uri redirect-uri
:response_type "code")
query (if state (assoc query :state state) query)
query (if access-type (assoc query :access_type access-type) query)
query (if scope
(assoc query :scope (str/join " " scope))
query)]
Expand All @@ -33,7 +34,7 @@

(defmulti prepare-access-token-request
(fn [request endpoint params]
(:grant-type endpoint)))
(name (:grant-type endpoint))))

(defmethod prepare-access-token-request
"authorization_code" [request endpoint params]
Expand Down Expand Up @@ -90,15 +91,16 @@
(if error
(if (string? error)
error
(:type error)) ; Facebookism
(:type error)) ; Facebookism
"unknown")))
{:access-token (:access_token body)
:token-type (or (:token_type body) "draft-10") ; Force.com
:query-param access-query-param
:params (dissoc body :access_token :token_type)})))
:params (dissoc body :access_token :token_type)
:refresh-token (:refresh_token body)})))

(defn get-access-token
[endpoint
[endpoint
& [params {expected-state :state expected-scope :scope}]]
(let [{:keys [state error]} params]
(cond (string? error)
Expand All @@ -119,7 +121,7 @@

(defmulti add-access-token-to-request
(fn [req oauth2]
(:token-type oauth2)))
(str/lower-case (:token-type oauth2))))

(defmethod add-access-token-to-request
:default [req oauth2]
Expand All @@ -134,7 +136,7 @@
(if access-token
[(if query-param
(assoc-in req [:query-params query-param] access-token)
(add-base64-auth-header req "Bearer" access-token))
(add-auth-header req "Bearer" access-token))
true]
[req false])))

Expand All @@ -159,6 +161,16 @@
(throw (OAuth2Exception. "Missing :oauth2 params"))
(client req))))))

(defn refresh-access-token
[refresh-token {:keys [client-id client-secret access-token-uri]}]
(let [req (http/post access-token-uri {:form-params
{:client_id client-id
:client_secret client-secret
:refresh_token refresh-token
:grant_type "refresh_token"}})]
(when (= (:status req) 200)
(read-json (:body req)))))

(def request
(wrap-oauth2 http/request))

Expand Down
229 changes: 120 additions & 109 deletions test/clj_oauth2/client_test.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
(ns clj-oauth2.client-test
(:use [lazytest.describe]
[lazytest.expect :only (expect)]
(:use clojure.test
[clojure.data.json :only [json-str]]
[clojure.pprint :only [pprint]])
(:require [clj-oauth2.client :as base]
Expand Down Expand Up @@ -51,6 +50,12 @@
{:username "foo"
:password "bar"})

(defn parse-auth-header [req]
(let [header (get-in req [:headers "authorization"] "")
[scheme param] (rest (re-matches #"\s*(\w+)\s+(.+)" header))]
(when-let [scheme (and scheme param (.toLowerCase scheme))]
[scheme param])))

(defn parse-base64-auth-header [req]
(let [header (get-in req [:headers "authorization"] "")
[scheme param] (rest (re-matches #"\s*(\w+)\s+(.+)" header))]
Expand All @@ -65,7 +70,7 @@

(defn handle-protected-resource [req grant & [deny]]
(let [query (uri/form-url-decode (:query-string req))
[scheme param] (parse-base64-auth-header req)
[scheme param] (parse-auth-header req)
bearer-token (and (= scheme "bearer") param)
token (or bearer-token (:access_token query))]
(if (= token (:access-token access-token))
Expand Down Expand Up @@ -156,118 +161,124 @@
(defonce server
(future (ring/run-jetty handler {:port 18080})))

(describe "grant-type authorization-code"
(given [req (base/make-auth-request endpoint-auth-code "bazqux")
uri (uri/uri->map (uri/make (:uri req)) true)]
(it "constructs a uri for the authorization redirect"
(and (= (:scheme uri) "http")
(= (:host uri) "localhost")
(= (:port uri) 18080)
(= (:path uri) "/auth")
(= (:query uri) {:response_type "code"
:client_id "foo"
:redirect_uri "http://my.host/cb"
:scope "foo bar"
:state "bazqux"})))
(it "contains the passed in scope and state"
(and (= (:scope req) ["foo" "bar"])
(= (:state req) "bazqux"))))
(deftest grant-type-auth-code
(let [req (base/make-auth-request endpoint-auth-code "bazqux")
uri (uri/uri->map (uri/make (:uri req)) true)]
(testing
"constructs a uri for the authorization redirect"
(is (= (:scheme uri) "http"))
(is (= (:host uri) "localhost"))
(is (= (:port uri) 18080))
(is (= (:path uri) "/auth"))
(is (= (:query uri) {:response_type "code"
:client_id "foo"
:redirect_uri "http://my.host/cb"
:scope "foo bar"
:state "bazqux"})))
(testing
"contains the passed in scope and state"
(is (= (:scope req) ["foo" "bar"]))
(is (= (:state req) "bazqux"))))

(testing base/get-access-token
(it "returns an access token hash-map on success"
(= (:access-token (base/get-access-token endpoint-auth-code
{:code "abracadabra" :state "foo"}
{:state "foo"}))
"sesame"))
(it "also works with client credentials passed in the authorization header"
(= (:access-token (base/get-access-token (assoc endpoint-auth-code
:authorization-header? true)
{:code "abracadabra" :state "foo"}
{:state "foo"}))
"sesame"))
(it "also works with application/x-www-form-urlencoded responses (as produced by Facebook)"
(= (:access-token (base/get-access-token (assoc endpoint-auth-code :access-token-uri
(str (:access-token-uri endpoint-auth-code)
"?formurlenc"))
(testing
base/get-access-token
(testing
"returns an access token hash-map on success"
(is (= (:access-token (base/get-access-token endpoint-auth-code
{:code "abracadabra" :state "foo"}
{:state "foo"}))
"sesame")))
(testing
"also works with client credentials passed in the authorization header"
(is (= (:access-token (base/get-access-token (assoc endpoint-auth-code
:authorization-header? true)
{:code "abracadabra" :state "foo"}
{:state "foo"}))
"sesame")))
(testing
"also works with application/x-www-form-urlencoded responses (as produced by Facebook)"
(is (= (:access-token (base/get-access-token (assoc endpoint-auth-code :access-token-uri
(str (:access-token-uri endpoint-auth-code)
"?formurlenc"))
{:code "abracadabra" :state "foo"}
{:state "foo"}))
"sesame")))
(testing
"returns an access token when no state is given"
(is (= (:access-token (base/get-access-token endpoint-auth-code {:code "abracadabra"}))
"sesame")))
(testing
"fails when state differs from expected state"
(is (thrown? OAuth2StateMismatchException
(base/get-access-token endpoint-auth-code
{:code "abracadabra" :state "foo"}
{:state "bar"}))))
(testing
"fails when an error response is passed in"
(is (thrown? OAuth2Exception
(base/get-access-token endpoint-auth-code
{:error "invalid_client"
:error_description "something went wrong"}))))
(testing
"raises on error response"
(is (thrown? OAuth2Exception
(base/get-access-token (assoc endpoint-auth-code
:access-token-uri
"http://localhost:18080/token-error")
{:code "abracadabra" :state "foo"}
{:state "foo"}))
"sesame"))
(it "returns an access token when no state is given"
(= (:access-token (base/get-access-token endpoint-auth-code {:code "abracadabra"}))
"sesame"))
(it "fails when state differs from expected state"
(throws? OAuth2StateMismatchException
(fn []
(base/get-access-token endpoint-auth-code
{:code "abracadabra" :state "foo"}
{:state "bar"}))))
(it "fails when an error response is passed in"
(throws? OAuth2Exception
(fn []
(base/get-access-token endpoint-auth-code
{:error "invalid_client"
:error_description "something went wrong"}))
(fn [e]
(expect (= ["something went wrong" "invalid_client"] @e)))))
(it "raises on error response"
(throws? OAuth2Exception
(fn []
(base/get-access-token (assoc endpoint-auth-code
:access-token-uri
"http://localhost:18080/token-error")
{:code "abracadabra" :state "foo"}
{:state "foo"}))
(fn [e]
(expect (= ["not good" "unauthorized_client"] @e)))))))
{:state "foo"}))))))

(describe "grant-type resource-owner"
(testing base/get-access-token
(it "returns an access token hash-map on success"
(= (:access-token (base/get-access-token endpoint-resource-owner resource-owner-credentials))
"sesame"))
(it "fails when invalid credentials are given"
(throws? OAuth2Exception
(fn []
(deftest grant-type-resource-owner
(testing
"returns an access token hash-map on success"
(is (= (:access-token (base/get-access-token endpoint-resource-owner resource-owner-credentials))
"sesame")))
(testing
"fails when invalid credentials are given"
(is (thrown? OAuth2Exception
(base/get-access-token
endpoint-resource-owner
{:username "foo" :password "qux"}))
(fn [e]
(expect (= ["invalid" "fail"] @e)))))))
endpoint-resource-owner
{:username "foo" :password "qux"})))))

(describe "token usage"
(it "should grant access to protected resources"
(= "that's gold jerry!"
(:body (base/request {:method :get
:oauth2 access-token
:url "http://localhost:18080/some-resource"}))))
(deftest token-usage
(testing
"should grant access to protected resources"
(is (= "that's gold jerry!"
(:body (base/request {:method :get
:oauth2 access-token
:url "http://localhost:18080/some-resource"})))))

(it "should preserve the url's query string when adding the access-token"
(= {:foo "123" (:query-param access-token) (:access-token access-token)}
(uri/form-url-decode
(:body (base/request {:method :get
:oauth2 access-token
:query-params {:foo "123"}
:url "http://localhost:18080/query-echo"})))))
(testing
"should preserve the url's query string when adding the access-token"
(is (= {:foo "123" (:query-param access-token) (:access-token access-token)}
(uri/form-url-decode
(:body (base/request {:method :get
:oauth2 access-token
:query-params {:foo "123"}
:url "http://localhost:18080/query-echo"}))))))

(it "should support passing bearer tokens through the authorization header"
(= {:foo "123" :access_token (:access-token access-token)}
(uri/form-url-decode
(:body (base/request {:method :get
:oauth2 (dissoc access-token :query-param)
:query-params {:foo "123"}
:url "http://localhost:18080/query-and-token-echo"})))))
(testing
"should support passing bearer tokens through the authorization header"
(is (= {:foo "123" :access_token (:access-token access-token)}
(uri/form-url-decode
(:body (base/request {:method :get
:oauth2 (dissoc access-token :query-param)
:query-params {:foo "123"}
:url "http://localhost:18080/query-and-token-echo"}))))))

(it "should deny access to protected resource given an invalid access token"
(= "nope"
(:body (base/request {:method :get
:oauth2 (assoc access-token :access-token "nope")
:url "http://localhost:18080/some-resource"
:throw-exceptions false}))))
(testing
"should deny access to protected resource given an invalid access token"
(is (= "nope"
(:body (base/request {:method :get
:oauth2 (assoc access-token :access-token "nope")
:url "http://localhost:18080/some-resource"
:throw-exceptions false})))))

(testing "pre-defined shortcut request functions"
(given [req {:oauth2 access-token}]
(it (= "get" (:body (base/get "http://localhost:18080/get" req))))
(it (= "post" (:body (base/post "http://localhost:18080/post" req))))
(it (= "put" (:body (base/put "http://localhost:18080/put" req))))
(it (= "delete" (:body (base/delete "http://localhost:18080/delete" req))))
(it (= 200 (:status (base/head "http://localhost:18080/head" req)))))))
(testing
"pre-defined shortcut request functions"
(let [req {:oauth2 access-token}]
(is (= "get" (:body (base/get "http://localhost:18080/get" req))))
(is (= "post" (:body (base/post "http://localhost:18080/post" req))))
(is (= "put" (:body (base/put "http://localhost:18080/put" req))))
(is (= "delete" (:body (base/delete "http://localhost:18080/delete" req))))
(is (= 200 (:status (base/head "http://localhost:18080/head" req)))))))