diff --git a/.gitignore b/.gitignore index b8c1b21..143d759 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ pom.xml /lib/ /classes/ .lein-deps-sum +/target +/.lein-failures diff --git a/project.clj b/project.clj index 4bcf9ff..62a4fbb 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,5 @@ -(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"] @@ -6,8 +7,7 @@ [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]) \ No newline at end of file + :aot [clj-oauth2.OAuth2Exception + clj-oauth2.OAuth2StateMismatchException]) diff --git a/src/clj_oauth2/client.clj b/src/clj_oauth2/client.clj index 7149745..e5c2b6e 100644 --- a/src/clj_oauth2/client.clj +++ b/src/clj_oauth2/client.clj @@ -9,7 +9,7 @@ [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) @@ -17,6 +17,7 @@ :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)] @@ -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] @@ -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) @@ -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] @@ -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]))) @@ -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)) diff --git a/test/clj_oauth2/client_test.clj b/test/clj_oauth2/client_test.clj index 8642298..a228524 100644 --- a/test/clj_oauth2/client_test.clj +++ b/test/clj_oauth2/client_test.clj @@ -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] @@ -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))] @@ -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)) @@ -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))))))) \ No newline at end of file + (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)))))))