├── LICENSE-2.0.txt ├── Makefile ├── README.md ├── fb_auth.erl ├── fb_auth.ini └── mocks ├── couch_auth_cache.erl ├── couch_config.erl ├── couch_db.erl ├── couch_db.hrl ├── couch_httpd.erl ├── couch_httpd_auth.erl └── couch_util.erl /LICENSE-2.0.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COUCH_ROOT = /opt/couchbase-server 2 | COUCHDB_ERLANG_LIB = $(COUCH_ROOT)/lib/couchdb/erlang/lib/couch-1.0.2 3 | COUCHDB_LOCALD = $(COUCH_ROOT)/etc/couchdb/local.d 4 | COUCHDB_INIT_SCRIPT = /etc/init.d/couchdb 5 | 6 | 7 | any: 8 | @echo "Targets are 'test', 'compile' and 'install'" 9 | 10 | test: clean 11 | # make sure test compilation code is not mistaken for real compiled codeA 12 | # by puting the beam in mocks/ 13 | erlc -DTEST -I mocks/ -o mocks/ fb_auth.erl 14 | (cd mocks && erlc *.erl) 15 | (cd mocks && erl -pa mocks/ -pa . -noshell -eval "eunit:test([fb_auth], [{report,{eunit_surefire,[{dir,\".\"}]}}])." -s erlang halt) 16 | 17 | install: compile 18 | sudo cp fb_auth.beam $(COUCHDB_ERLANG_LIB)/ebin/ 19 | sudo cp fb_auth.ini $(COUCHDB_LOCALD) 20 | sudo $(COUCHDB_INIT_SCRIPT) restart 21 | 22 | compile: clean 23 | erlc -I $(COUCHDB_ERLANG_LIB)/include/ fb_auth.erl 24 | 25 | clean: 26 | rm -f *.beam 27 | rm -f mocks/*.beam 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The newer CouchDB-XO\_Auth project does this better so I suggest looking at that rather than this. 2 | 3 | Facebook Authentication for CouchDB 4 | =================================== 5 | 6 | CouchDB has a number of Authentication modules built in (cookie, default & OAuth) and several open source projects for others such as LDAP and 7 | OpenID. This project adds a Facebook authentication module that uses the 8 | [Facebook authentication API](http://developers.Facebook.com/docs/authentication/ ) to log people in. 9 | 10 | It consists of a CouchDB httpd\_global\_handler that responds to http GET requests and 11 | initiates the Facebook login sequence. Once logged in, the handler requests an access 12 | code and retrieves the Facebook profile information via https://graph.Facebook.com/me. The user's Facebook ID can 13 | then be used to retrieve the CouchDB users document from the authentication_db to create the CouchDB session. The access code is stored 14 | in the user record. If there isn't a matching user record then one is created using the Facebook ID as the username 15 | 16 | An error during Facebook login will return HTTP 403 to the original caller. 17 | 18 | Example Flow of Control 19 | --------------------------- 20 | 21 | For our example we assume the app is hosted at my_site.com 22 | 23 | 1. The user clicks the link my_site.com/_fb 24 | 2. The _\_fb_ page redirects user to Facebook so user can approve the apps access to their details 25 | 3. The user logs into Facebook (if not already logged in) and approves the app (if 26 | not already approved). NOTE: If the user was already logged in and has already approved the app then 27 | they will be redirected back to your app without needing to type anything. 28 | 4. The user arrives back at the _\_fb\_resp_ page with a code that allows this module 29 | to contact Facebook via the API and request an Access Code. No user interaction 30 | required. 31 | 5. Once the Access Code is granted it allows the module to retrieve the users id 32 | (used as the couch users username when creating the couch user), and is stored 33 | in the users record so that it can be used by the app. 34 | 6. When the couch session expires (the app will have to catch this) you simply 35 | need to send the user to _\_fb_ again and, because they've already gone thru this 36 | route, it should get you all the way back to _\_fb\_resp_ without any user 37 | interaction needed. 38 | 7. The final act is to redirect the user back into the app, a location that is 39 | a combination of _client\_app\_uri_ config directive and the _clientapptoken_ 40 | param to _\_fb_. 41 | 42 | 43 | Build 44 | -------------------- 45 | 46 | There is an unsophisticated Makefile with targets for _test_ (requires eunit), _compile_ and _install_. 47 | 48 | In order to compile and install this module you might have to edit the Makefile and change one or more of _COUCH\_ROOT_, _\_COUCHDB\_ERLANG\_LIB_, _COUCHDB\_LOCALD_ and _COUCHDB\_INIT\_SCRIPT_ values to point to the appropriate directories and file within your couchdb installation. 49 | 50 | 51 | Installation 52 | ------------------- 53 | 54 | You need to copy the beam file to somewhere where couch can find it. That location could be something like couchdb/erlang/lib/couch-1.0.1/ebin/ it depends on where/how you've installed couch. 55 | 56 | You'll also need to create a [Facebook app](See http://developer.Facebook.com) 57 | 58 | Configuration 59 | -------------------- 60 | You'll need to add an ini file or ini entries in couch config to use this module. 61 | 62 | [httpd_global_handlers] 63 | _fb = {fb_auth, handle_fb_req} 64 | _fb_resp = {fb_auth, handle_fb_resp_req} 65 | 66 | [fb] 67 | scope=offline_access,user_about_me 68 | client_id=1234567890 69 | redirect_uri=http://my_awesome_app.com/_fb_resp 70 | client_secret=1234567890ABCDEF123456789 71 | destination_db=_users 72 | client_app_uri=http://my_awesome_app.com/home? 73 | 74 | 75 | **\_fb** 76 | This is the couch location for the code that redirects the user to Facebook. 77 | Pass in a param called _clientapptoken_ if you want something added to the 78 | client app redirect at the end of the auth process (for when control is 79 | returned to your app). 80 | 81 | **\_fb\_resp** 82 | This is the couch location that Facebook should redirect back to. This should 83 | match the _redirect\_uri_ field in the [fb] section. 84 | 85 | NOTE: Facebook requires that your site is public. The 'Site URL' setting of 86 | the Facebook app needs to be set to your site. 87 | 88 | **scope** 89 | A comma separated list of the scope of access required to Facebook by the app. 90 | See Facebook dev docs for all the scope names. At least _offline\_access_ and 91 | _user\_about\_me_ are required. 92 | 93 | **client_id** 94 | The App ID of your Facebook app 95 | 96 | **redirect_uri** 97 | This is the location that Facebook will be told to return the user to when 98 | they're done logging in. This MUST start with the same location that you set 99 | for Site URL in the Facebook app 100 | 101 | **client\_secret** 102 | DO NOT PUBLISH THIS! It is the _App Secret_ from your Facebook app. 103 | It is used behind the scenes to contact Facebook. If anyone gets hold 104 | of this they can pretend to be your app. Beware! 105 | 106 | **destination\_db** 107 | This is the name of the database that the user record will be created in. 108 | This is probably _\_users_ , but is configurable in case you want to test 109 | the app without risking your existing _users database. 110 | 111 | **client\_app\_uri** 112 | When _\_fb\_resp_ has been reached it will then need to return the flow to 113 | your app. You control where this redirect location with this setting. 114 | Any value passed to the initial _\_fb__ call param _clientapptoken_ will be 115 | appended to this URL. 116 | 117 | Licenses 118 | --------------- 119 | 120 | CouchDB-Facebook Authentication is licensed under: Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ 121 | 122 | Copyright (c) 2011 Ocasta Labs Ltd. 123 | 124 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 125 | 126 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 127 | 128 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 129 | 130 | -------------------------------------------------------------------------------- /fb_auth.erl: -------------------------------------------------------------------------------- 1 | -module(fb_auth). 2 | 3 | -export([handle_fb_req/1]). 4 | -export([handle_fb_resp_req/1]). 5 | 6 | -include("couch_db.hrl"). 7 | 8 | 9 | 10 | 11 | %% Exported functions 12 | 13 | handle_fb_req(#httpd{method='GET'}=Req) -> 14 | % Get values from the config file 15 | [Scope, ClientID, RedirectURI] = get_config(["scope", "client_id", "redirect_uri"]), 16 | 17 | % extract the client apps token so that we can pass it thru the facebook api 18 | % (this allows the client app to pass some state) 19 | FullRedirectUrl = case get_encoded_client_app_redirect_from_qs(Req) of 20 | "" -> 21 | couch_util:url_encode(RedirectURI); 22 | CAT -> 23 | couch_util:url_encode(RedirectURI++"?clientapptoken="++CAT) 24 | end, 25 | % Construct the FB oauth URL 26 | Url = "https://www.facebook.com/dialog/oauth?client_id="++ClientID++"&scope="++Scope++"&redirect_uri="++FullRedirectUrl, 27 | ?LOG_DEBUG("handle_fb_req - redirecting to ~p", [Url]), 28 | 29 | % Redirect the client to the FB Oauth page 30 | couch_httpd:send_json(Req, 302, [{"Location", Url}], {[]}); 31 | 32 | handle_fb_req(Req) -> 33 | couch_httpd:send_method_not_allowed(Req, "GET"). 34 | 35 | 36 | handle_fb_resp_req(#httpd{method='GET'}=Req) -> 37 | % Did we get a 'code' or 'error' back from facebook? 38 | case couch_httpd:qs_value(Req, "code") of 39 | undefined -> 40 | ?LOG_DEBUG("Facebook responded with something other than a code: ~p", [Req]), 41 | couch_httpd:send_json(Req, 403, [], {[{error, <<"No code supplied">>}]}); 42 | Code -> 43 | handle_fb_code(Req, Code) 44 | end; 45 | 46 | handle_fb_resp_req(Req) -> 47 | couch_httpd:send_method_not_allowed(Req, "GET"). 48 | 49 | 50 | %% Private and Utility functions 51 | 52 | get_unmodified_client_app_redirect_from_qs(Req) -> 53 | case couch_httpd:qs_value(Req, "clientapptoken") of 54 | undefined -> ""; 55 | Cat -> Cat 56 | end. 57 | 58 | get_encoded_client_app_redirect_from_qs(Req) -> 59 | couch_util:url_encode( get_unmodified_client_app_redirect_from_qs(Req) ). 60 | 61 | handle_fb_code(Req, FBCode) -> 62 | % Extract required values from config ini 63 | [RedirectURI, ClientID, ClientSecret, DestDBName] = get_config(["redirect_uri", "client_id", "client_secret", "destination_db"]), 64 | 65 | % if the client passed in a client app token then facebook should have passed it back to us, 66 | % so extract it. 67 | ClientAppToken = get_encoded_client_app_redirect_from_qs(Req), 68 | 69 | % Get an access token from Facebook 70 | case request_facebook_access_token(ClientAppToken, RedirectURI, ClientID, ClientSecret, FBCode) of 71 | {ok, AccessToken} -> 72 | % Retrieve info from the graph/me API call 73 | case request_facebook_graphme_info(AccessToken) of 74 | {ok, ID} -> 75 | % Create or update user auth doc with access token 76 | case update_or_create_user_doc(DestDBName, ID, AccessToken) of 77 | {ok, Rev} -> 78 | ?LOG_DEBUG("Updated user doc for facebook id ~p is rev ~p", [ID, Rev]), 79 | % Finally send a response that includes the AuthSession cookie 80 | generate_cookied_response_json(ID, Req, AccessToken); 81 | Error -> 82 | ?LOG_DEBUG("Non-success from update_or_create_user_doc call: ~p", [Error]), 83 | couch_httpd:send_json(Req, 403, [], {[{error, <<"Unable to update doc">>}]}) 84 | end; 85 | Error -> 86 | ?LOG_DEBUG("Non-success from request_facebook_graphme_info call: ~p", [Error]), 87 | couch_httpd:send_json(Req, 403, [], {[{error, <<"Failed graphme request">>}]}) 88 | end; 89 | Error -> 90 | ?LOG_DEBUG("Non-success from request_facebook_access_token call: ~p", [Error]), 91 | couch_httpd:send_json(Req, 403, [], {[{error, <<"Could not get access token">>}]}) 92 | end. 93 | 94 | 95 | get_config(Keys) -> 96 | lists:map( 97 | fun(K) -> 98 | case couch_config:get("fb", K, undefined) of 99 | undefined -> 100 | throw({missing_config_value, "Cannot find key '"++K++"' in [fb] section of config"}); 101 | Any -> 102 | Any 103 | end 104 | end, Keys). 105 | 106 | 107 | generate_cookied_response_json(ID, Req, AccessToken) -> 108 | % Create an auth cookie in the same way that couch_httpd_auth.erl does. 109 | % NOTE: This could be fragile! If couch_httpd_auth.erl changes the way it handles 110 | % auth cookie then this code will break. However, couch_httpd_auth.erl doesn't 111 | % seem to expose enough method for us to make it do all the work. 112 | User = case couch_auth_cache:get_user_creds(ID) of 113 | nil -> []; 114 | Result -> Result 115 | end, 116 | UserSalt = couch_util:get_value(<<"salt">>, User, <<>>), 117 | Secret=?l2b( case couch_config:get("couch_httpd_auth", "secret", nil) of 118 | nil -> 119 | NewSecret = ?b2l(couch_uuids:random()), 120 | couch_config:set("couch_httpd_auth", "secret", NewSecret), 121 | NewSecret; 122 | Sec -> Sec 123 | end ), 124 | 125 | % Create a json response containing some useful info and the AuthSession 126 | % cookie. 127 | ClientAppUri = couch_config:get("fb", "client_app_uri", nil), 128 | couch_httpd:send_json(Req, 302, 129 | [{"Location", ClientAppUri++get_unmodified_client_app_redirect_from_qs(Req) }] ++ 130 | couch_httpd_auth:cookie_auth_header(Req#httpd{user_ctx=#user_ctx{name=ID}, auth={<>, true}}, []), 131 | {[ 132 | {fbid, ID}, 133 | {access_token, ?l2b(AccessToken)} 134 | ]} 135 | ). 136 | 137 | update_or_create_user_doc(DestDBName, ID, AccessToken) -> 138 | % Generate a _users compatible ID 139 | FullID=?l2b("org.couchdb.user:"++ID), 140 | 141 | % Open the database 142 | {ok, Db} = couch_db:open_int(?l2b(DestDBName), []), 143 | 144 | % Read and ammend existing doc, or create a new one 145 | NewDoc = case couch_db:open_doc_int(Db, FullID, []) of 146 | {ok, #doc{deleted=false}=OrigDoc} -> 147 | ?LOG_INFO("Updating user doc in ~p for facebook id ~p (couch id ~p). New access token is ~p", [DestDBName, ID, FullID, AccessToken]), 148 | OrigDoc#doc{ 149 | body=couch_util:json_apply_field({?l2b("fb_access_token"), ?l2b(AccessToken)}, OrigDoc#doc.body) 150 | }; 151 | _ -> 152 | ?LOG_INFO("No user doc found in ~p for facebook id ~p (couch id ~p), creating a new one.", [DestDBName, ID, FullID]), 153 | #doc{ 154 | id=FullID, 155 | body={[ 156 | {?l2b("_id"), FullID}, 157 | {?l2b("fb_access_token"), ?l2b(AccessToken)}, 158 | {?l2b("name"), ID}, 159 | {?l2b("roles"), []}, 160 | {?l2b("type"), ?l2b("user")} 161 | ]} 162 | } 163 | end, 164 | 165 | % To prevent the validation functions for the db taking umbridge at our 166 | % behind the scenes twiddling, we blank them out. 167 | % NOTE: Potentially fragile. Possibly dangerous? 168 | % TODO: Make this configurable? 169 | % TODO: If nothing has changed, there's no need for a write. For now, we write anyway. 170 | DbWithoutValidationFunc = Db#db{ validate_doc_funs=[] }, 171 | couch_db:update_doc(DbWithoutValidationFunc, NewDoc, []). 172 | 173 | 174 | request_facebook_graphme_info(AccessToken) -> 175 | % Construct the URL to access the graph API's /me page 176 | Url="https://graph.facebook.com/me?access_token="++AccessToken, 177 | ?LOG_DEBUG("Url=~p",[Url]), 178 | 179 | % Request the page 180 | Resp=http:request(Url), 181 | ?LOG_DEBUG("request_facebook_graphme_info response=~p",[Resp]), 182 | 183 | process_facebook_graphme_response(Resp). 184 | 185 | process_facebook_graphme_response(Resp) -> 186 | % Extract user facebook id from the body 187 | case Resp of 188 | {ok, {{_,200,_}, _, Body}} -> 189 | % Decode the facebook response body, extracting the 190 | % ID and the complete response. 191 | {FBInfo}=?JSON_DECODE(Body), 192 | ID=couch_util:get_value(<<"id">>, FBInfo), 193 | {ok, ID}; 194 | _ -> 195 | {error, "Non 200 response from facebook"} 196 | end. 197 | 198 | 199 | request_facebook_access_token(ClientAppToken, RedirectURI, ClientID, ClientSecret, FBCode) -> 200 | % Construct the access token request URL. 201 | % NOTE: We do not use type=client_type because if we do then we don't get a 202 | % session access code back, and without that we are unable to use the /me 203 | % alias of the graph API. The redirect_uri is ignored by us, but mandated 204 | % by the API. 205 | 206 | FullRedirectUrl = case ClientAppToken of 207 | "" -> 208 | couch_util:url_encode(RedirectURI); 209 | CAT -> 210 | couch_util:url_encode(RedirectURI++"?clientapptoken="++CAT) 211 | end, 212 | Url="https://graph.facebook.com/oauth/access_token?&client_id="++ClientID++"&client_secret="++ClientSecret++"&code="++FBCode++"&redirect_uri="++FullRedirectUrl, 213 | ?LOG_DEBUG("request_facebook_access_token: requesting using URL - ~p", [Url]), 214 | 215 | % Request the page 216 | Resp=http:request(Url), 217 | ?LOG_DEBUG("Full response from Facebook: ~p", [Resp]), 218 | 219 | process_facebook_access_token(Resp). 220 | 221 | 222 | process_facebook_access_token(Resp) -> 223 | % Extract the info we need 224 | case Resp of 225 | {ok, {{_,200,_}, _, Body}} -> 226 | case string:tokens(Body, "=") of 227 | ["access_token", AccessToken] -> 228 | ?LOG_DEBUG("process_facebook_access_token: access_token=~p",[AccessToken]), 229 | {ok, AccessToken}; 230 | _ -> 231 | ?LOG_DEBUG("process_facebook_access_token: unexpected response: ~p", [Body]), 232 | {error, "Unexpected body response from facebook"} 233 | end; 234 | _ -> 235 | ?LOG_DEBUG("process_facebook_access_token: non 200 response of: ~p", [Resp]), 236 | {error, "Non 200 response from facebook"} 237 | end. 238 | 239 | 240 | 241 | 242 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 243 | %%%% Unit Tests - compilation conditional, see Makefile %%%% 244 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 245 | 246 | -ifdef(TEST). 247 | 248 | -include_lib("eunit/include/eunit.hrl"). 249 | 250 | generate_cookied_response_json_test() -> 251 | { 252 | send_json_called, {req, _Req}, {code, Code}, {headers, Headers}, {body, Body} 253 | } = generate_cookied_response_json("12345", #httpd{method='GET', user_ctx=[], auth=[], clientapptoken="SomePlace"}, "some_access_token"), 254 | ?assertEqual(302, Code), 255 | ?assertEqual([{"Location","http://example.com/superapp?SomePlace"}| <<"AuthSession=SomeTokenHere">>], Headers), 256 | ?assertEqual({[{fbid,"12345"},{access_token,<<"some_access_token">>}]}, Body). 257 | 258 | process_facebook_graphme_response__success_test() -> 259 | % success path 260 | ?assertEqual( 261 | {ok, "AnIdFromFacebook"}, 262 | process_facebook_graphme_response({ok, {{blah,200,blah}, blah, "JsonStringFbGraphMeResponse"}}) 263 | ). 264 | process_facebook_graphme_response__non_200_response_code_test() -> 265 | % faulure path - unexpected response code 266 | ?assertEqual( 267 | {error, "Non 200 response from facebook"}, 268 | process_facebook_graphme_response({ok, {{blah,404,blah}, blah, "JsonStringFbGraphMeResponse"}}) 269 | ). 270 | process_facebook_graphme_response__non_ok_test() -> 271 | % faulure path - non-ok from httpc 272 | ?assertEqual( 273 | {error, "Non 200 response from facebook"}, 274 | process_facebook_graphme_response({error, some_kind_of_error}) 275 | ). 276 | 277 | request_facebook_access_token__success__test() -> 278 | % success path 279 | ?assertEqual( 280 | {ok, "GroovyAccessToken"}, 281 | process_facebook_access_token({ok, {{blah,200,blah}, blah, "access_token=GroovyAccessToken"}}) 282 | ). 283 | request_facebook_access_token__bad_body__test() -> 284 | % failure path - bad body content 285 | ?assertEqual( 286 | {error, "Unexpected body response from facebook"}, 287 | process_facebook_access_token({ok, {{blah,200,blah}, blah, "something_unexpected"}}) 288 | ). 289 | request_facebook_access_token__non_200_response_code__test() -> 290 | % failure path - unexpected response code 291 | ?assertEqual( 292 | {error, "Non 200 response from facebook"}, 293 | process_facebook_access_token({ok, {{blah,876,blah}, blah, "access_token=GroovyAccessToken"}}) 294 | ). 295 | request_facebook_access_token__non_ok__test() -> 296 | % failure path - non-ok from httpc 297 | ?assertEqual( 298 | {error, "Non 200 response from facebook"}, 299 | process_facebook_access_token({error, some_kind_of_error}) 300 | ). 301 | 302 | % NOTE: See mocks/couch_db.erl to see what makes these tests tick. It makes heavy use of 303 | % fixures and pattern matching. Thus *** it's likely to fail with badmatch rather 304 | % than an assert failure if the implementation changes ***. 305 | update_or_create_user_doc__non_existing_user_test() -> 306 | % Non existing user 307 | ?assertEqual( 308 | {ok, some_new_rev}, 309 | update_or_create_user_doc("_some_db", "123456_non_existing_user", "MyGreatAccessToken") 310 | ). 311 | update_or_create_user_doc__prev_del_user_test() -> 312 | % A previusly deleted users record will be found by open_doc_int UNLESS 313 | % you specifically make sure the deleted flag is set to false. 314 | ?assertEqual( 315 | {ok, some_new_rev}, 316 | update_or_create_user_doc("_some_db", "222222_prev_deleted_user", "MySuperAccessToken") 317 | ). 318 | update_or_create_user_doc__existing_user_test() -> 319 | % Existing user 320 | ?assertEqual( 321 | {ok, some_new_rev}, 322 | update_or_create_user_doc("_some_db", "888888_existing_user", "MyOtherGreatAccessToken") 323 | ). 324 | 325 | get_config__success_test() -> 326 | ?assertEqual( 327 | ["some redirect_uri","some client_id","some client_secret"], 328 | get_config(["redirect_uri", "client_id", "client_secret"]) 329 | ). 330 | get_config__missing_test() -> 331 | ?assertThrow( 332 | {missing_config_value, "Cannot find key 'DOES NOT EXIST' in [fb] section of config"}, 333 | get_config(["redirect_uri", "DOES NOT EXIST", "client_secret"]) 334 | ). 335 | 336 | get_encoded_client_app_redirect_from_qs_test() -> 337 | % make sure couch_util:url_encode is called on the string (see mock) 338 | Req=#httpd{clientapptoken="FooBar"}, 339 | ?assertEqual( 340 | "url_encode(FooBar)", 341 | get_encoded_client_app_redirect_from_qs(Req) 342 | ). 343 | 344 | get_unmodified_client_app_redirect_from_qs_undef_test() -> 345 | ?assertEqual( 346 | "", 347 | get_unmodified_client_app_redirect_from_qs(#httpd{}) 348 | ). 349 | 350 | get_unmodified_client_app_redirect_from_qs_test() -> 351 | ?assertEqual( 352 | "Meow!", 353 | get_unmodified_client_app_redirect_from_qs(#httpd{ clientapptoken="Meow!" }) 354 | ). 355 | 356 | -endif. 357 | -------------------------------------------------------------------------------- /fb_auth.ini: -------------------------------------------------------------------------------- 1 | [httpd_global_handlers] 2 | _fb = {fb_auth, handle_fb_req} 3 | _fb_resp = {fb_auth, handle_fb_resp_req} 4 | 5 | [fb] 6 | scope=offline_access,user_about_me 7 | client_id=1234567890 8 | redirect_uri=http://my_awesome_app.com/_fb_resp 9 | client_secret=1234567890ABCDEF123456789 10 | destination_db=_users 11 | client_app_uri=http://my_awesome_app.com/home? 12 | -------------------------------------------------------------------------------- /mocks/couch_auth_cache.erl: -------------------------------------------------------------------------------- 1 | -module(couch_auth_cache). 2 | -compile(export_all). 3 | 4 | get_user_creds(ID) -> 5 | "user creds for "++ID. 6 | 7 | -------------------------------------------------------------------------------- /mocks/couch_config.erl: -------------------------------------------------------------------------------- 1 | -module(couch_config). 2 | -compile(export_all). 3 | 4 | get("couch_httpd_auth", "secret", _Default) -> 5 | "something"; 6 | 7 | get("fb", "redirect_uri", _Default) -> 8 | "some redirect_uri"; 9 | get("fb", "client_id", _Default) -> 10 | "some client_id"; 11 | get("fb", "client_secret", _Default) -> 12 | "some client_secret"; 13 | get("fb","destination_db",null) -> 14 | "_the_users_db"; 15 | get("fb","client_app_uri",nil) -> 16 | "http://example.com/superapp?"; 17 | 18 | get(_,_,_) -> 19 | undefined. 20 | -------------------------------------------------------------------------------- /mocks/couch_db.erl: -------------------------------------------------------------------------------- 1 | -module(couch_db). 2 | -compile(export_all). 3 | 4 | -include("couch_db.hrl"). 5 | 6 | open_int(<<"_some_db">>, []) -> 7 | {ok, #db{}}. 8 | 9 | % Fixtures for various user cases 10 | open_doc_int({db,[]},<<"org.couchdb.user:123456_non_existing_user">>,[]) -> 11 | something_not_ok; 12 | open_doc_int({db,[]},<<"org.couchdb.user:888888_existing_user">>,[]) -> 13 | {ok, #doc{ 14 | deleted=false, 15 | id=list_to_binary("org.couchdb.user:888888_existing_user"), 16 | body={[ 17 | {<<"_id">>,<<"org.couchdb.user:888888_existing_user">>}, 18 | {<<"name">>,"888888_existing_user"}, 19 | {<<"roles">>,[]}, 20 | {<<"type">>,<<"user">>} 21 | ]} 22 | }}; 23 | open_doc_int({db,[]},<<"org.couchdb.user:222222_prev_deleted_user">>,[]) -> 24 | {ok, #doc{deleted=true}}. 25 | 26 | % The expected document update for the fixture cases 27 | update_doc({db,[]},{doc,<<"org.couchdb.user:123456_non_existing_user">>, 28 | {[ 29 | {<<"_id">>,<<"org.couchdb.user:123456_non_existing_user">>}, 30 | {<<"fb_access_token">>,<<"MyGreatAccessToken">>}, 31 | {<<"name">>,"123456_non_existing_user"}, 32 | {<<"roles">>,[]}, 33 | {<<"type">>,<<"user">>} 34 | ]}, 35 | false},[]) -> 36 | {ok, some_new_rev}; 37 | update_doc({db,[]},{doc,<<"org.couchdb.user:222222_prev_deleted_user">>, 38 | {[ 39 | {<<"_id">>,<<"org.couchdb.user:222222_prev_deleted_user">>}, 40 | {<<"fb_access_token">>,<<"MySuperAccessToken">>}, 41 | {<<"name">>,"222222_prev_deleted_user"}, 42 | {<<"roles">>,[]}, 43 | {<<"type">>,<<"user">>} 44 | ]}, 45 | false},[]) -> 46 | {ok, some_new_rev}; 47 | update_doc({db,[]},{doc,<<"org.couchdb.user:888888_existing_user">>, 48 | {[ 49 | {<<"fb_access_token">>,<<"MyOtherGreatAccessToken">>}, 50 | {<<"type">>,<<"user">>}, 51 | {<<"roles">>,[]}, 52 | {<<"name">>,"888888_existing_user"}, 53 | {<<"_id">>,<<"org.couchdb.user:888888_existing_user">>} 54 | ]}, 55 | false},[]) -> 56 | {ok, some_new_rev}. 57 | 58 | -------------------------------------------------------------------------------- /mocks/couch_db.hrl: -------------------------------------------------------------------------------- 1 | % Based on couch.hrl 2 | % Only stubs what's used by the module. 3 | 4 | -define(b2l(V), binary_to_list(V)). 5 | -define(l2b(V), list_to_binary(V)). 6 | 7 | -define(LOG_DEBUG(Format, Args), io:format(Format, Args)). 8 | -define(LOG_INFO(Format, Args) , io:format(Format, Args)). 9 | -define(LOG_ERROR(Format, Args), io:format(Format, Args)). 10 | 11 | -record(httpd, 12 | { 13 | method, 14 | user_ctx, 15 | auth, 16 | clientapptoken 17 | }). 18 | 19 | -record(doc, 20 | { 21 | id = <<"">>, 22 | body = {[]}, 23 | deleted = false 24 | }). 25 | 26 | -record(user_ctx, 27 | { 28 | name=null, 29 | roles=[], 30 | handler 31 | }). 32 | 33 | -record(db, 34 | { 35 | validate_doc_funs = [] 36 | }). 37 | 38 | -------------------------------------------------------------------------------- /mocks/couch_httpd.erl: -------------------------------------------------------------------------------- 1 | -module(couch_httpd). 2 | -compile(export_all). 3 | 4 | -include("couch_db.hrl"). 5 | 6 | send_json(Req, Code, Headers, Body) -> 7 | { send_json_called, 8 | { req, Req }, 9 | { code, Code }, 10 | { headers, Headers }, 11 | { body, Body } 12 | }. 13 | 14 | qs_value(Req, "clientapptoken") -> 15 | Req#httpd.clientapptoken. 16 | 17 | -------------------------------------------------------------------------------- /mocks/couch_httpd_auth.erl: -------------------------------------------------------------------------------- 1 | -module(couch_httpd_auth). 2 | -compile(export_all). 3 | 4 | cookie_auth_header(_Req, []) -> 5 | <<"AuthSession=SomeTokenHere">>. 6 | -------------------------------------------------------------------------------- /mocks/couch_util.erl: -------------------------------------------------------------------------------- 1 | -module(couch_util). 2 | -compile(export_all). 3 | 4 | get_value(Key, Proplist) -> 5 | get_value(Key, Proplist, undefined). 6 | get_value(Key, Proplist, Default) -> 7 | proplists:get_value(Key, Proplist, Default). 8 | 9 | json_decode("JsonStringFbGraphMeResponse") -> 10 | { 11 | [ 12 | {<<"id">>, "AnIdFromFacebook"} 13 | ] 14 | }. 15 | 16 | % json_apply_field and proplist_apply_field are copied 17 | % directly from the real couch_util.erl 18 | json_apply_field(H, {L}) -> 19 | json_apply_field(H, L, []). 20 | json_apply_field({Key, NewValue}, [{Key, _OldVal} | Headers], Acc) -> 21 | json_apply_field({Key, NewValue}, Headers, Acc); 22 | json_apply_field({Key, NewValue}, [{OtherKey, OtherVal} | Headers], Acc) -> 23 | json_apply_field({Key, NewValue}, Headers, [{OtherKey, OtherVal} | Acc]); 24 | json_apply_field({Key, NewValue}, [], Acc) -> 25 | {[{Key, NewValue}|Acc]}. 26 | 27 | proplist_apply_field(H, L) -> 28 | {R} = json_apply_field(H, {L}), 29 | R. 30 | 31 | url_encode(Something) -> 32 | % we're not going to really encode it, just stamp it so 33 | % we can tell it was called. 34 | "url_encode("++Something++")". 35 | --------------------------------------------------------------------------------