Permalink
Browse files

initial commit

  • Loading branch information...
0 parents commit 20882cae76421159fc4245008fca80e787f522ef @dvv committed Mar 15, 2013
Showing with 461 additions and 0 deletions.
  1. +10 −0 .gitignore
  2. +18 −0 LICENSE.txt
  3. +75 −0 README.md
  4. +13 −0 rebar.config
  5. +96 −0 src/cowboy_request.erl
  6. +12 −0 src/cowboy_social.app.src
  7. +131 −0 src/cowboy_social.erl
  8. +106 −0 src/cowboy_social_providers.erl
@@ -0,0 +1,10 @@
+ebin
+rel
+deps
+.eunit
+.ct
+.project_plt
+logs
+test/*.beam
+erl_crash.dump
+*.swp
@@ -0,0 +1,18 @@
+Copyright (c) 2013 Vladimir Dronnikov <dronnikov@gmail.com>
+
+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:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+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.
@@ -0,0 +1,75 @@
+Social
+==============
+
+Handler for social login via OAuth2 providers.
+
+Usage
+--------------
+
+Register your application with callback URI pointing back to your site plus `/auth/:provider/callback`, and supply `ClientID` and `ClientSecret` in provider's configuration proplist.
+
+Then in your client-side code:
+```html
+<a href="/auth/google/login">Auth via Google</a>
+```
+
+Router configuration
+--------------
+
+```erlang
+{"/auth/:provider/:action", cowboy_social, [
+ % oauth2 parameters
+ {oauth2_opts, [
+
+ % at the end of the flow this handler will be called as
+ % Mod:Fun({ok, Profile}, Req) or Mod:Fun({error, Reason}, Req)
+ {callback, {Mod, Fun}},
+
+ % allowed providers
+ {providers, [
+ {google, <<"google">>, [
+ {client_id, <<"440647648374.apps.googleusercontent.com">>},
+ {client_secret, <<"...">>},
+ {scope, <<"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile">>}
+ ]},
+ {github, <<"github">>, [
+ {client_id, <<"883b68d607abddc24f77">>},
+ {client_secret, <<"...">>},
+ {scope, <<>>}
+ ]},
+ {vkontakte, <<"vkontakte">>, [
+ {client_id, <<"3473116">>},
+ {client_secret, <<"...">>},
+ {scope, <<"uid,first_name,last_name,sex,photo">>}
+ ]},
+ {yandex, <<"yandex">>, [
+ {client_id, <<"f44bd59ddfbe408ab1d29151126385a6">>},
+ {client_secret, <<"...">>},
+ {scope, <<>>}
+ ]}
+ ]}
+ ]}
+]}
+```
+
+License (MIT)
+-------
+
+Copyright (c) 2013 Vladimir Dronnikov <dronnikov@gmail.com>
+
+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:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+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.
@@ -0,0 +1,13 @@
+{erl_opts, [
+ debug_info,
+ warn_format,
+ warn_export_vars,
+ warn_obsolete_guard,
+ warn_bif_clash
+]}.
+
+{deps, [
+ {jsx, "", {git, "https://github.com/talentdeficit/jsx.git", {branch, "HEAD"}}}
+]}.
+
+{cover_enabled, true}.
@@ -0,0 +1,96 @@
+%%
+%%------------------------------------------------------------------------------
+%% !!! Hackish reuse of cowboy_client undocumented interface !!!
+%%------------------------------------------------------------------------------
+%%
+
+-module(cowboy_request).
+-author('Vladimir Dronnikov <dronnikov@gmail.com>').
+
+-export([get_json/2]).
+-export([post_for_json/2]).
+-export([request/4]).
+-export([urlencode/1]).
+
+-record(client, {
+ state = wait :: wait | request | response | response_body,
+ opts = [] :: [any()],
+ socket = undefined :: undefined | inet:socket(),
+ transport = undefined :: module(),
+ timeout = 5000 :: timeout(), %% @todo Configurable.
+ buffer = <<>> :: binary(),
+ connection = keepalive :: keepalive | close,
+ version = {1, 1} :: cowboy_http:version(),
+ response_body = undefined :: undefined | non_neg_integer()
+}).
+
+request(Method, URL, Headers, Body) ->
+% pecypc_log:info({req, Method, URL, Body}),
+ {ok, Client0} = cowboy_client:init([]),
+ % NB: have to degrade protocol to not allow chunked responses
+ Client = Client0#client{version = {1, 0}},
+ {ok, Client2} = cowboy_client:request(Method, URL, [
+ {<<"connection">>, <<"close">>},
+ {<<"accept-encoding">>, <<"identity">>},
+ {<<"accept">>, <<"application/json">>},
+ {<<"pragma">>, <<"no-cache">>},
+ {<<"cache-control">>,
+ <<"private, max-age: 0, no-cache, must-revalidate">>}
+ | Headers
+ ], Body, Client),
+ Result = case cowboy_client:response(Client2) of
+ {ok, 200, _ResHeaders, Client3} ->
+ case Client3#client.state of
+ % @fixme dirty hack, reports only first read chunk
+ request ->
+ {ok, Client3#client.buffer};
+ response_body ->
+ case cowboy_client:response_body(Client3) of
+ {ok, ResBody, _} ->
+ {ok, ResBody};
+ Else ->
+ Else
+ end
+ end;
+ _Else ->
+% pecypc_log:info({reqerr, _Else}),
+ {error, failed}
+ end,
+% pecypc_log:info({res, _ResHeaders, Result}),
+ Result.
+
+urlencode(Bin) when is_binary(Bin) ->
+ cowboy_http:urlencode(Bin);
+urlencode(Atom) when is_atom(Atom) ->
+ urlencode(atom_to_binary(Atom, latin1));
+urlencode(List) when is_list(List) ->
+ urlencode(List, <<>>).
+urlencode([], Acc) ->
+ Acc;
+urlencode([{K, V} | T], <<>>) ->
+ urlencode(T, << (urlencode(K))/binary, $=, (urlencode(V))/binary >>);
+urlencode([{K, V} | T], Acc) ->
+ urlencode(T, << Acc/binary, $&,
+ (urlencode(K))/binary, $=, (urlencode(V))/binary >>).
+
+parse({ok, JSON}) ->
+ case jsx:decode(JSON, [{error_handler, fun(_, _, _) -> {error, badarg} end}])
+ of
+ {error, _} ->
+ % {ok, cowboy_http:x_www_form_urlencoded(JSON)};
+ {error, badarg};
+ Hash ->
+ {ok, Hash}
+ end;
+parse(_) ->
+ {error, badarg}.
+
+get_json(URL, Data) ->
+ parse(request(<<"GET">>,
+ << URL/binary, $?,
+ (urlencode(Data))/binary >>, [], <<>>)).
+
+post_for_json(URL, Data) ->
+ parse(request(<<"POST">>, URL, [
+ {<<"content-type">>, <<"application/x-www-form-urlencoded">>}
+ ], urlencode(Data))).
@@ -0,0 +1,12 @@
+{application, cowboy_social, [
+ {description, "OAuth2 social login client for Cowboy web server"},
+ {vsn, "0.0.1"},
+ {registered, []},
+ {applications, [
+ kernel,
+ stdlib,
+ ssl,
+ cowboy
+ ]},
+ {env, []}
+]}.
@@ -0,0 +1,131 @@
+%%
+%% @doc Handler for social login via OAuth2 providers.
+%%
+
+-module(cowboy_social).
+-author('Vladimir Dronnikov <dronnikov@gmail.com>').
+
+-behaviour(cowboy_http_handler).
+-export([init/3, terminate/3, handle/2]).
+
+%%
+%%------------------------------------------------------------------------------
+%% OAUTH2 Application flow
+%%------------------------------------------------------------------------------
+%%
+
+init(_Transport, Req, Opts) ->
+ {ok, Req, Opts}.
+
+terminate(_Reason, _Req, _State) ->
+ ok.
+
+%%
+%% {"/auth/:provider/:action", cowboy_social, [...]}.
+%%
+handle(Req, Opts) ->
+ {Provider, Req2} = cowboy_req:binding(provider, Req),
+ % ensure provider for this request exists
+ Oauth2Opts = key(oauth2_opts, Opts),
+ case lists:keyfind(Provider, 2, key(providers, Oauth2Opts)) of
+ % provider found
+ {PName, Provider, POpts} ->
+ % extract flow action name
+ {Action, Req3} = cowboy_req:binding(action, Req2),
+ Req4 = cowboy_req:set_meta(callback, key(callback, Oauth2Opts), Req3),
+ % construct callback URI
+ {SelfUri, Req5} = cowboy_req:host_url(Req4),
+ [FullPath] = cowboy_req:get([path], Req5),
+ CallbackUrl = << SelfUri/binary, FullPath/binary >>,
+ % perform flow action
+ {ok, Req6} = handle_request(Action, PName, POpts, CallbackUrl, Req5),
+ {ok, Req6, undefined};
+ % no provider or bad configuration
+ _ ->
+ {ok, Req6} = cowboy_req:reply(404, [], <<>>, Req2),
+ {ok, Req6, undefined}
+ end.
+
+%%
+%% redirect to provider authorization page, expect it to redirect
+%% to our next handler
+%%
+handle_request(<<"login">>, P, O, U, Req) ->
+ AuthUrl = << (cowboy_social_providers:authorize_url(P))/binary, $?,
+ (cowboy_request:urlencode([
+ {<<"client_id">>, key(client_id, O)},
+ {<<"redirect_uri">>, binary:replace(U, <<"/login">>, <<"/callback">>)},
+ {<<"response_type">>, <<"code">>},
+ {<<"scope">>, key(scope, O)}
+ ]))/binary >>,
+ cowboy_req:reply(303, [{<<"location">>, AuthUrl}], <<>>, Req);
+
+%%
+%% provider redirected back to us with authorization code
+%%
+handle_request(<<"callback">>, P, O, U, Req) ->
+ case cowboy_req:qs_val(<<"code">>, Req) of
+ {undefined, Req2} ->
+ finish({error, nocode}, Req2);
+ {Code, Req2} ->
+ get_access_token(P, O, U, Code, Req2)
+ end;
+
+%%
+%% catchall
+%%
+handle_request(_, _, _, _, Req) ->
+ {ok, Req2} = cowboy_req:reply(404, [], <<>>, Req),
+ {ok, Req2, undefined}.
+
+%%
+%% exchange authorization code for auth token
+%%
+get_access_token(P, O, U, Code, Req) ->
+ case cowboy_request:post_for_json(cowboy_social_providers:token_url(P), [
+ {<<"code">>, Code},
+ {<<"client_id">>, key(client_id, O)},
+ {<<"client_secret">>, key(client_secret, O)},
+ {<<"redirect_uri">>, U},
+ {<<"grant_type">>, <<"authorization_code">>}
+ ])
+ of
+ {ok, Auth} ->
+ get_user_profile(P, O, Auth, Req);
+ _ ->
+ finish({error, notoken}, Req)
+ end.
+
+%%
+%% use auth tocken to extract info from user profile
+%%
+get_user_profile(P, O, Auth, Req) ->
+ AccessToken = key(<<"access_token">>, Auth),
+ case cowboy_request:get_json(cowboy_social_providers:profile_url(P), [
+ {<<"access_token">>, AccessToken}
+ | cowboy_social_providers:custom_data(P, AccessToken, O)
+ ])
+ of
+ {ok, Profile} ->
+ finish({ok, cowboy_social_providers:normalize_profile(P, Auth, Profile)},
+ Req);
+ _ ->
+ finish({error, noprofile}, Req)
+ end.
+
+%%
+%% finalize application flow by calling callback handler
+%%
+finish(Status, Req) ->
+ {{M, F}, Req2} = cowboy_req:meta(callback, Req),
+ M:F(Status, Req2).
+
+%%
+%%------------------------------------------------------------------------------
+%% Helpers
+%%------------------------------------------------------------------------------
+%%
+
+key(Key, List) ->
+ {_, Value} = lists:keyfind(Key, 1, List),
+ Value.
Oops, something went wrong.

0 comments on commit 20882ca

Please sign in to comment.