-
Notifications
You must be signed in to change notification settings - Fork 43
/
elixir_auth_google.ex
210 lines (176 loc) · 6.76 KB
/
elixir_auth_google.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
defmodule ElixirAuthGoogle do
@moduledoc """
Minimalist Google OAuth Authentication for Elixir Apps.
Extensively tested, documented, maintained and in active use in production.
"""
@google_auth_url "https://accounts.google.com/o/oauth2/v2/auth?response_type=code"
@google_token_url "https://oauth2.googleapis.com/token"
@google_user_profile "https://www.googleapis.com/oauth2/v3/userinfo"
@default_scope "profile email"
@default_callback_path "/auth/google/callback"
@httpoison (Application.compile_env(:elixir_auth_google, :httpoison_mock) &&
ElixirAuthGoogle.HTTPoisonMock) || HTTPoison
@type conn :: map
@type url :: String.t()
@doc """
`inject_poison/0` injects a TestDouble of HTTPoison in Test
so that we don't have duplicate mock in consuming apps.
see: github.com/dwyl/elixir-auth-google/issues/35
"""
def inject_poison, do: @httpoison
@doc """
`get_baseurl_from_conn/1` derives the base URL from the conn struct
"""
@spec get_baseurl_from_conn(conn) :: String.t()
def get_baseurl_from_conn(%{host: h, port: p, scheme: s}) when p != 80 do
"#{Atom.to_string(s)}://#{h}:#{p}"
end
def get_baseurl_from_conn(%{host: h, scheme: s}) do
"#{Atom.to_string(s)}://#{h}"
end
def get_baseurl_from_conn(%{host: h} = conn) do
scheme =
case h do
"localhost" -> :http
_ -> :https
end
get_baseurl_from_conn(Map.put(conn, :scheme, scheme))
end
@doc """
`generate_redirect_uri/1` generates the Google redirect uri based on `conn`
or the `url`. If the `App.Endpoint.url()`
e.g: auth.dwyl.com or https://gcal.fly.dev
is passed into `generate_redirect_uri/1`,
return that `url` with the callback appended to it.
See: github.com/dwyl/elixir-auth-google/issues/94
"""
@spec generate_redirect_uri(url) :: String.t()
def generate_redirect_uri(url) when is_binary(url) do
scheme =
cond do
# url already contains scheme return empty
String.contains?(url, "https") -> ""
# url contains ":" is localhost:4000 no need for scheme
String.contains?(url, ":") -> ""
# Default to https if scheme not set e.g: app.fly.dev -> https://app.fly.fev
true -> "https://"
end
"#{scheme}#{url}" <> get_app_callback_url()
end
@spec generate_redirect_uri(conn) :: String.t()
def generate_redirect_uri(conn) do
get_baseurl_from_conn(conn) <> get_app_callback_url()
end
@doc """
`generate_oauth_url/1` creates the Google OAuth2 URL with client_id, scope and
redirect_uri which is the URL Google will redirect to when auth is successful.
This is the URL you need to use for your "Login with Google" button.
See step 5 of the instructions.
"""
@spec generate_oauth_url(String.t()) :: String.t()
def generate_oauth_url(url) when is_binary(url) do
query = %{
client_id: google_client_id(),
scope: google_scope(),
redirect_uri: generate_redirect_uri(url)
}
params = URI.encode_query(query, :rfc3986)
"#{@google_auth_url}&#{params}"
end
@spec generate_oauth_url(conn) :: String.t()
def generate_oauth_url(conn) when is_map(conn) do
query = %{
client_id: google_client_id(),
scope: google_scope(),
redirect_uri: generate_redirect_uri(conn)
}
params = URI.encode_query(query, :rfc3986)
"#{@google_auth_url}&#{params}"
end
@doc """
Same as `generate_oauth_url/1` with `state` query parameter,
or a `map` of key/pair values to be included in the urls query string.
"""
@spec generate_oauth_url(conn, String.t() | map) :: String.t()
def generate_oauth_url(conn, state) when is_binary(state) do
params = URI.encode_query(%{state: state}, :rfc3986)
generate_oauth_url(conn) <> "&#{params}"
end
def generate_oauth_url(conn, query) when is_map(query) do
query = URI.encode_query(query, :rfc3986)
generate_oauth_url(conn) <> "&#{query}"
end
@doc """
`get_token/2` encodes the secret keys and authorization code returned by Google
and issues an HTTP request to get a person's profile data.
**TODO**: we still need to handle the various failure conditions >> issues/16
"""
@spec get_token(String.t(), conn) :: {:ok, map} | {:error, any}
def get_token(code, conn) when is_map(conn) do
redirect_uri = generate_redirect_uri(conn)
inject_poison().post(@google_token_url, req_body(code, redirect_uri))
|> parse_body_response()
end
@spec get_token(String.t(), url) :: {:ok, map} | {:error, any}
def get_token(code, url) when is_binary(url) do
redirect_uri = generate_redirect_uri(url)
inject_poison().post(@google_token_url, req_body(code, redirect_uri))
|> parse_body_response()
end
defp req_body(code, redirect_uri) do
Jason.encode!(%{
client_id: google_client_id(),
client_secret: google_client_secret(),
redirect_uri: redirect_uri,
grant_type: "authorization_code",
code: code
})
end
@doc """
`get_user_profile/1` requests the Google User's userinfo profile data
providing the access_token received in the `get_token/1` above.
invokes `parse_body_response/1` to decode the JSON data.
**TODO**: we still need to handle the various failure conditions >> issues/16
At this point the types of errors we expect are HTTP 40x/50x responses.
"""
@spec get_user_profile(String.t()) :: {:ok, map} | {:error, any}
def get_user_profile(token) do
params = URI.encode_query(%{access_token: token}, :rfc3986)
"#{@google_user_profile}?#{params}"
|> inject_poison().get()
|> parse_body_response()
end
@doc """
`parse_body_response/1` parses the response returned by Google
so your app can use the resulting JSON.
"""
@spec parse_body_response({atom, String.t()} | {:error, any}) :: {:ok, map} | {:error, any}
def parse_body_response({:error, err}), do: {:error, err}
def parse_body_response({:ok, response}) do
body = Map.get(response, :body)
# make keys of map atoms for easier access in templates
if body == nil do
{:error, :no_body}
else
{:ok, str_key_map} = Jason.decode(body)
atom_key_map = for {key, val} <- str_key_map, into: %{}, do: {String.to_atom(key), val}
{:ok, atom_key_map}
end
# https://stackoverflow.com/questions/31990134
end
def google_client_id do
System.get_env("GOOGLE_CLIENT_ID") || Application.get_env(:elixir_auth_google, :client_id)
end
defp google_client_secret do
System.get_env("GOOGLE_CLIENT_SECRET") ||
Application.get_env(:elixir_auth_google, :client_secret)
end
defp google_scope do
System.get_env("GOOGLE_SCOPE") || Application.get_env(:elixir_auth_google, :google_scope) ||
@default_scope
end
defp get_app_callback_url do
System.get_env("GOOGLE_CALLBACK_PATH") ||
Application.get_env(:elixir_auth_google, :callback_path) || @default_callback_path
end
end