├── README ├── rebar.config └── src ├── simple_oauth2.app.src └── simple_oauth2.erl /README: -------------------------------------------------------------------------------- 1 | simple_oauth2 2 | ============== 3 | 4 | Simple OAuth2 client for any http server framework 5 | 6 | Inspired by and based on https://github.com/dvv/social 7 | Preconfigured to authorize users of Google, Facebook, Yandex, Vkontakte, Mail.ru 8 | 9 | 10 | Usage 11 | -------------- 12 | 13 | 1. Create OAuth2 web site app keys at 14 | Google: https://code.google.com/apis/console/b/0/ 15 | Facebook: https://developers.facebook.com/apps/ 16 | Yandex: https://oauth.yandex.ru/client/new 17 | Vkontakte: http://vk.com/dev 18 | 19 | 2. Integrate into you http server framework 20 | 21 | Example for MochiWeb (https://github.com/mochi/mochiweb): 22 | 23 | SocNets = simple_oauth2:customize_networks(simple_oauth2:predefined_networks(), [ 24 | {<<"google">>, [ 25 | {client_id, <<"GOOGLE_CLIENT_ID">>}, 26 | {client_secret, <<"GOOGLE_CLIENT_SECRET">>} 27 | ]}, 28 | {<<"yandex">>, [ 29 | {client_id, <<"YANDEX_CLIENT_ID">>}, 30 | {client_secret, <<"YANDEX_CLIENT_SECRET">>} 31 | ]}, 32 | {<<"vkontakte">>, [ 33 | {client_id, <<"VK_CLIENT_ID">>}, 34 | {client_secret, <<"VK_CLIENT_SECRET">>} 35 | ]}, 36 | {<<"facebook">>, [ 37 | {client_id, <<"FB_CLIENT_ID">>}, 38 | {client_secret, <<"FB_CLIENT_SECRET">>} 39 | ]}, 40 | {<<"mailru">>, [ 41 | {client_id, <<"MR_CLIENT_ID">>}, 42 | {client_secret, <<"MR_CLIENT_SECRET">>}, 43 | {client_secret_key, <<"MR_CLIENT_SECRET_KEY">>} 44 | ]} 45 | ]), 46 | UrlPrefix = iolist_to_binary([atom_to_list(Req:get(scheme)), "://", 47 | Req:get_header_value("host")]), 48 | case simple_oauth2:dispatcher(list_to_binary(Req:get(raw_path)), UrlPrefix, SocNets) of 49 | {redirect, Where} -> Req:respond({302, 50 | [{"Location", simple_oauth2:gather_url_get(Where)}], []}); 51 | {send_html, HTML} -> Req:ok({"text/html; charset=utf-8", HTML}); 52 | {ok, AuthData} -> 53 | Req:ok({"text/plain; charset=utf-8", io_lib:format("~p~n", [AuthData])}); 54 | {error, Class, Reason} -> 55 | Req:ok({"text/plain; charset=utf-8", 56 | io_lib:format("Error: ~p ~p~n", [Class, Reason])}) 57 | end. 58 | 59 | 3. Start ssl and inets erlang applications before 60 | 61 | You can easily add more social networks (see simple_oauth2:predefined_networks()) 62 | with OAuth2 authorization. 63 | 64 | 65 | Known issues (TODO) 66 | -------------- 67 | 68 | 1. Support of GitHub, Paypal is upcoming 69 | 2. Vk doesn't provide email 70 | 3. Yandex doesn't provide picture 71 | 72 | 73 | License (MIT) 74 | -------------- 75 | 76 | Copyright (c) 2013 Igor Milyakov 77 | 78 | Permission is hereby granted, free of charge, to any person obtaining a copy of 79 | this software and associated documentation files (the "Software"), to deal in 80 | the Software without restriction, including without limitation the rights to 81 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 82 | the Software, and to permit persons to whom the Software is furnished to do so, 83 | subject to the following conditions: 84 | 85 | The above copyright notice and this permission notice shall be included in all 86 | copies or substantial portions of the Software. 87 | 88 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 89 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 90 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 91 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 92 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 93 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 94 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {deps, [ 2 | {jsx, ".*", 3 | {git, "git://github.com/talentdeficit/jsx.git", "main"}} 4 | ]}. 5 | -------------------------------------------------------------------------------- /src/simple_oauth2.app.src: -------------------------------------------------------------------------------- 1 | {application, simple_oauth2, [ 2 | {description, "Simple OAuth2 social login client"}, 3 | {vsn, "1.0.0"}, 4 | {registered, []}, 5 | {applications, [ 6 | kernel, 7 | stdlib, 8 | ssl, 9 | inets 10 | ]}, 11 | {dependencies, [jsx]}, 12 | {modules, [simple_oauth2]}, 13 | {env, []} 14 | ]}. 15 | -------------------------------------------------------------------------------- /src/simple_oauth2.erl: -------------------------------------------------------------------------------- 1 | -module(simple_oauth2). 2 | -author('Igor Milyakov '). 3 | 4 | -export([ 5 | dispatcher/3, 6 | predefined_networks/0, customize_networks/2, 7 | gather_url_get/1 8 | ]). 9 | 10 | -import(proplists, [get_value/2, get_value/3]). 11 | 12 | predefined_networks() -> 13 | [ 14 | {<<"google">>, [ % https://code.google.com/apis/console/b/0/ 15 | {callback_uri, <<"/auth/google/callback">>}, 16 | {scope, << "https://www.googleapis.com/auth/userinfo.email ", 17 | "https://www.googleapis.com/auth/userinfo.profile" >>}, 18 | {authorize_uri, <<"https://accounts.google.com/o/oauth2/auth">>}, 19 | {token_uri, <<"https://accounts.google.com/o/oauth2/token">>}, 20 | {userinfo_uri, <<"https://www.googleapis.com/oauth2/v1/userinfo">>}, 21 | {userinfo_params, [{access_token, access_token}]}, 22 | {field_names, [id, email, name, picture, gender, locale]} 23 | ]}, 24 | {<<"facebook">>, [ % https://developers.facebook.com/apps/ 25 | {callback_uri, <<"/auth/facebook/callback">>}, 26 | {scope, <<"email">>}, 27 | {authorize_uri, <<"https://www.facebook.com/dialog/oauth">>}, 28 | {token_uri, <<"https://graph.facebook.com/oauth/access_token">>}, 29 | {userinfo_uri, <<"https://graph.facebook.com/me">>}, 30 | {userinfo_params, [{access_token, access_token}, 31 | {fields, <<"id,email,name,picture,gender,locale">>}]}, 32 | {field_names, [id, email, name, picture, gender, locale]}, 33 | {field_fix, fun(picture, Profile, _) -> 34 | get_value(<<"url">>, 35 | get_value(<<"data">>, 36 | get_value(<<"picture">>, Profile))); 37 | (Other, Profile, Default) -> Default(Other, Profile) end} 38 | ]}, 39 | {<<"yandex">>, [ % https://oauth.yandex.ru/client/new 40 | {callback_uri, <<"/auth/yandex/callback">>}, 41 | {scope, <<"login:birthday login:email login:info">>}, 42 | {authorize_uri, <<"https://oauth.yandex.ru/authorize">>}, 43 | {token_uri, <<"https://oauth.yandex.ru/token">>}, 44 | {userinfo_uri, <<"https://login.yandex.ru/info">>}, 45 | {userinfo_params, [{oauth_token, access_token}, {format, <<"json">>}]}, 46 | {field_names, [id, default_email, real_name, picture, sex, undefined]} 47 | ]}, 48 | {<<"vkontakte">>, [ % http://vk.com/dev 49 | {callback_uri, <<"/auth/vkontakte/callback">>}, 50 | {scope, <<"uid,first_name,last_name,sex,photo">>}, 51 | {authorize_uri, <<"https://oauth.vk.com/authorize">>}, 52 | {token_uri, <<"https://oauth.vk.com/access_token">>}, 53 | {userinfo_uri, <<"https://api.vk.com/method/users.get">>}, 54 | {userinfo_params, [{access_token, access_token}, 55 | {fields, <<"uid,first_name,last_name,sex,photo">>}]}, 56 | {field_names, [uid, undefined, name, photo, gender, undefined]}, 57 | {field_pre, fun(Profile) -> hd(get_value(<<"response">>, Profile)) end}, 58 | {field_fix, fun(name, Profile, _) -> 59 | << (get_value(<<"first_name">>, Profile))/binary, 60 | " ", 61 | (get_value(<<"last_name">>, Profile))/binary >>; 62 | (gender, Profile, _) -> case get_value(<<"sex">>, Profile) of 63 | 1 -> <<"female">>; _ -> <<"male">> end; 64 | (Other, Profile, Default) -> Default(Other, Profile) end} 65 | ]}, 66 | {<<"mailru">>, [ 67 | {callback_uri, <<"/auth/mailru/callback">>}, 68 | {scope, <<>>}, 69 | {authorize_uri, <<"https://connect.mail.ru/oauth/authorize">>}, 70 | {token_uri, <<"https://connect.mail.ru/oauth/token">>}, 71 | {userinfo_uri, <<"http://www.appsmail.ru/platform/api">>}, 72 | {userinfo_composer, fun(Auth, Network) -> 73 | [ 74 | {app_id, get_value(client_id, Network)}, 75 | {method, <<"users.getInfo">>}, 76 | {secure, <<"1">>}, 77 | {session_key, get_value(access_token, Auth)}, 78 | {sig, list_to_binary(lists:flatten( 79 | [io_lib:format("~2.16.0b", [X]) || X <- binary_to_list(erlang:md5( 80 | <<"app_id=", (get_value(client_id, Network))/binary, 81 | "method=users.getInfosecure=1session_key=", 82 | (get_value(access_token, Auth))/binary, 83 | (get_value(client_secret_key, Network))/binary>> 84 | ))]))} 85 | ] end}, 86 | {field_names, [uid, email, name, pic, sex, undefined]}, 87 | {field_pre, fun(Profile) -> hd(Profile) end}, 88 | {field_fix, fun(name, Profile, _) -> 89 | << (get_value(<<"first_name">>, Profile))/binary, 90 | " ", 91 | (get_value(<<"last_name">>, Profile))/binary >>; 92 | (sex, Profile, _) -> case get_value(<<"sex">>, Profile) of 93 | 1 -> <<"female">>; _ -> <<"male">> end; 94 | (Other, Profile, Default) -> Default(Other, Profile) end} 95 | 96 | ]}, 97 | {<<"paypal">>, [ 98 | {callback_uri, <<"/auth/paypal/callback">>}, 99 | {scope, <<"https://identity.x.com/xidentity/resources/profile/me">>}, 100 | {authorize_uri, <<"https://identity.x.com/xidentity/resources/authorize">>}, 101 | {token_uri, <<"https://identity.x.com/xidentity/oauthtokenservice">>} 102 | ]}, 103 | {<<"github">>, [ 104 | {callback_uri, <<"/auth/github/callback">>}, 105 | {scope, <<>>}, 106 | {authorize_uri, <<"https://github.com/login/oauth/authorize">>}, 107 | {token_uri, <<"https://github.com/login/oauth/access_token">>} 108 | ]} 109 | ]. 110 | 111 | customize_networks(Networks, Customization) -> 112 | [ 113 | {Network, fun() -> 114 | CustOpts = get_value(Network, Customization), 115 | {_, Rem} = proplists:split(Options, proplists:get_keys(CustOpts)), 116 | CustOpts ++ Rem 117 | end()} 118 | || {Network, Options} <- Networks, 119 | get_value(Network, Customization, undefined) =/= undefined 120 | ]. 121 | 122 | parse_gets(<<>>) -> []; 123 | parse_gets(GetString) -> 124 | [{list_to_binary(K), list_to_binary(V)} || 125 | {K, V} <- httpd:parse_query(binary_to_list(GetString))]. 126 | 127 | dispatcher(Request, LocalUrlPrefix, Networks) -> 128 | [Path | PreGets] = binary:split(Request, <<"?">>), 129 | [NetName, Action] = binary:split(Path, <<"/">>), 130 | Gets = case PreGets of 131 | [] -> []; 132 | [QString] -> parse_gets(QString) 133 | end, 134 | Network = get_value(NetName, Networks), 135 | case {Network, Action} of 136 | {undefined, _} -> {error, unknown_network, "Unknown or not customized social network"}; 137 | {_, <<"login">>} -> 138 | {redirect, 139 | {get_value(authorize_uri, Network), [ 140 | {client_id, get_value(client_id, Network)}, 141 | {redirect_uri, iolist_to_binary([LocalUrlPrefix, 142 | get_value(callback_uri, Network)])}, 143 | {response_type, get_value(<<"response_type">>, Gets, <<"code">>)}, 144 | {scope, get_value(scope, Network)}, 145 | {state, get_value(<<"state">>, Gets, <<>>)} 146 | ]} 147 | }; 148 | {_, <<"callback">>} -> 149 | case get_value(<<"error">>, Gets, undefined) of 150 | undefined -> case get_value(<<"code">>, Gets, undefined) of 151 | undefined -> case get_value(<<"access_token">>, Gets, undefined) of 152 | undefined -> {send_html, << 153 | "" 156 | >>}; 157 | Token -> {ok, get_profile_info(Network, [ 158 | {network, NetName}, 159 | {access_token, Token}, 160 | {token_type, get_value(<<"token_type">>, Gets, 161 | <<"bearer">>)} 162 | ])} 163 | end; 164 | Code -> 165 | post({NetName, Network}, get_value(token_uri, Network), [ 166 | {code, Code}, 167 | {client_id, get_value(client_id, Network)}, 168 | {client_secret, get_value(client_secret, Network)}, 169 | {redirect_uri, iolist_to_binary([LocalUrlPrefix, 170 | get_value(callback_uri, Network)])}, 171 | {grant_type, <<"authorization_code">>} 172 | ]) 173 | end; 174 | Error -> {error, auth_error, Error} 175 | end 176 | end. 177 | 178 | urlencoded_parse(Data) -> 179 | Parsed = parse_gets(Data), 180 | ParsedLength = length(Parsed), 181 | CleanLength = length([{K, V} || {K, V} <- Parsed, K =/= <<>>, V =/= <<>>]), 182 | if 183 | CleanLength == ParsedLength -> Parsed; 184 | true -> {error, json_error, "Can't parse json"} 185 | end. 186 | 187 | json_parse(JSON) -> 188 | case jsx:decode(JSON, [{error_handler, fun(_, _, _) -> {error, unsuccessful} end}]) of 189 | {error, _} -> urlencoded_parse(JSON); 190 | {incomplete, _} -> urlencoded_parse(JSON); 191 | Parsed -> Parsed 192 | end. 193 | 194 | http_request_json(Method, Request, OnSuccess) -> 195 | case httpc:request(Method, Request, 196 | [{timeout, 10000}, {connect_timeout, 20000}, {autoredirect, true}], 197 | [{body_format, binary}, {full_result, false}]) of 198 | {ok, {200, JSON}} -> OnSuccess(JSON); 199 | {ok, {Code, _Ret}} -> {error, post_error, lists:flatten("Post returned non-200 code: " ++ 200 | integer_to_list(Code) ++ _Ret)}; 201 | {error, Reason} -> {error, http_request_error, Reason} 202 | end. 203 | 204 | post({NetName, Network}, Url, Params) -> 205 | http_request_json(post, {binary_to_list(Url), [], "application/x-www-form-urlencoded", 206 | url_encode(Params)}, 207 | fun(JSON) -> case json_parse(JSON) of 208 | {error, _, _} = Error -> Error; 209 | Hash -> case get_value(<<"error">>, Hash, undefined) of 210 | undefined -> {ok, get_profile_info(Network, [ 211 | {network, NetName}, 212 | {access_token, get_value(<<"access_token">>, Hash)}, 213 | {token_type, get_value(<<"token_type">>, Hash, <<"bearer">>)} 214 | ])}; 215 | Error -> {error, unsuccessful, Error} 216 | end 217 | end 218 | end). 219 | 220 | url_encode(Data) -> url_encode(Data,""). 221 | url_encode([],Acc) -> list_to_binary(Acc); 222 | url_encode([{Key,Value}|R],"") -> 223 | url_encode(R, edoc_lib:escape_uri(atom_to_list(Key)) ++ "=" ++ 224 | edoc_lib:escape_uri(binary_to_list(Value))); 225 | url_encode([{Key,Value}|R],Acc) -> 226 | url_encode(R, Acc ++ "&" ++ edoc_lib:escape_uri(atom_to_list(Key)) ++ "=" ++ 227 | edoc_lib:escape_uri(binary_to_list(Value))). 228 | 229 | gather_url_get({Path, QueryString}) -> 230 | iolist_to_binary([Path, 231 | case lists:flatten([ 232 | ["&", edoc_lib:escape_uri(atom_to_list(K)), "=", edoc_lib:escape_uri(binary_to_list(V))] 233 | || {K, V} <- QueryString 234 | ]) of [] -> []; [_ | QS] -> [$? | QS] end]). 235 | 236 | get_profile_info(Network, Auth) -> 237 | http_request_json(get, {binary_to_list(gather_url_get( 238 | {get_value(userinfo_uri, Network), 239 | case get_value(userinfo_composer, Network) of 240 | undefined -> lists:map(fun({K, access_token}) -> 241 | {K, get_value(access_token, Auth)}; 242 | (P) -> P end, get_value(userinfo_params, Network)); 243 | UIComp -> UIComp(Auth, Network) 244 | end})), []}, 245 | fun(JSON) -> case json_parse(JSON) of 246 | {error, _, _} = Error -> Error; 247 | Profile -> Profile1 = case get_value(field_pre, Network) of 248 | undefined -> Profile; F -> F(Profile) end, 249 | [{Field, case {get_value(field_fix, Network), fun(Na, Pro) -> 250 | get_value(list_to_binary(atom_to_list(Na)), Pro) end} of 251 | {undefined, DefF} -> DefF(Name, Profile1); 252 | {Func, DefF} -> Func(Name, Profile1, DefF) 253 | end} 254 | || {Field, Name} <- lists:zip([id, email, name, picture, gender, locale], 255 | get_value(field_names, Network))] ++ [{raw, Profile} | Auth] 256 | end 257 | end). 258 | --------------------------------------------------------------------------------