Skip to content
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)))))))