defmodule Goth.Token do
@moduledoc """
Functions for retrieving the token from the Google API.
"""
@type t :: %__MODULE__{
token: String.t(),
type: String.t(),
scope: String.t(),
expires: non_neg_integer,
sub: String.t() | nil
}
defstruct [
:token,
:type,
:scope,
:sub,
:expires,
# Deprecated fields:
:account
]
@default_url "https://www.googleapis.com/oauth2/v4/token"
@default_scopes ["https://www.googleapis.com/auth/cloud-platform"]
@doc """
Fetch the token from the Google API using the given `config`.
Config may contain the following keys:
* `:source` - the source to retrieve the token from.
Supported values include:
* `{:service_account, credentials}` - for fetching token using service account credentials
* `{:refresh_token, credentials}` - for fetching token using refresh token
* `:metadata` - for fetching token using Google internal metadata service
If `:source` is not set, Goth will:
* Check application environment. You can set it with: `config :goth, json: File.read!("credentials.json")`.
* Check `GOOGLE_APPLICATION_CREDENTIALS` env variable that contains path to credentials file.
* Check `GOOGLE_APPLICATION_CREDENTIALS_JSON` env variable that contains credentials JSON.
* Check `~/.config/gcloud/application_default_credentials.json` file.
* Check Google internal metadata service
* Otherwise, raise an error.
See "Source" section below for more information.
* `:http_client` - a function that makes the HTTP request.
Can be one of the following:
* `fun` - same as `{fun, []}`
* `{fun, opts}` - `fun` must be a 1-arity function that receives a keyword list with fields
`:method`, `:url`, `:headers`, and `:body` along with any passed `opts`. The function must return
`{:ok, %{status: status, headers: headers, body: body}}` or `{:error, exception}`.
See "Custom HTTP Client" section below for more information.
`fun` can also be an atom `:finch` to use the built-in [Finch](http://github.com/sneako/finch)-based
client.
Defaults to `{:finch, []}`.
## Source
Source can be one of:
#### Service account - `{:service_account, credentials}`
Same as `{:service_account, credentials, []}`
#### Service account - `{:service_account, credentials, options}`
The `credentials` is a map and can contain the following keys:
* `"private_key"`
* `"client_email"`
The `options` is a keywords list and can contain the following keys:
* `:url` - the URL of the authentication service, defaults to:
`"https://www.googleapis.com/oauth2/v4/token"`
* `:scopes` - the list of token scopes, defaults to `#{inspect(@default_scopes)}` (ignored if `:claims` present)
* `:claims` - self-signed JWT extra claims. Should be a map with string keys only.
A self-signed JWT will be [exchanged for a Google-signed ID token](https://cloud.google.com/functions/docs/securing/authenticating#exchanging_a_self-signed_jwt_for_a_google-signed_id_token)
#### Refresh token - `{:refresh_token, credentials}`
Same as `{:refresh_token, credentials, []}`
#### Refresh token - `{:refresh_token, credentials, options}`
The `credentials` is a map and can contain the following keys:
* `"refresh_token"`
* `"client_id"`
* `"client_secret"`
The `options` is a keywords list and can contain the following keys:
* `:url` - the URL of the authentication service, defaults to:
`"https://www.googleapis.com/oauth2/v4/token"`
#### Google metadata server - `:metadata`
Same as `{:metadata, []}`
#### Google metadata server - `{:metadata, options}`
The `options` is a keywords list and can contain the following keys:
* `:account` - the name of the account to generate the token for, defaults to `"default"`
* `:url` - the URL of the metadata server, defaults to `"http://metadata.google.internal"`
* `:audience` - the audience you want an identity token for, default to `nil`
If this parameter is provided, an identity token is returned instead of an access token
## Custom HTTP Client
To use a custom HTTP client, define a function that receives a keyword list with fields
`:method`, `:url`, `:headers`, and `:body`. The function must return
`{:ok, %{status: status, headers: headers, body: body}}` or `{:error, exception}`.
Here's an example with Finch:
defmodule MyApp do
def request_with_finch(options) do
{method, options} = Keyword.pop!(options, :method)
{url, options} = Keyword.pop!(options, :url)
{headers, options} = Keyword.pop!(options, :headers)
{body, options} = Keyword.pop!(options, :body)
Finch.build(method, url, headers, body)
|> Finch.request(Goth.Finch, options)
end
end
And here is how it can be used:
iex> Goth.Token.fetch(source: source, http_client: &MyApp.request_with_finch/1)
{:ok, %Goth.Token{...}}
iex> Goth.Token.fetch(source: source, http_client: {&MyApp.request_with_finch/1, receive_timeout: 5000})
{:ok, %Goth.Token{...}}
## Examples
#### Generate a token using a service account credentials file:
iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
iex> Goth.Token.fetch(source: {:service_account, credentials})
{:ok, %Goth.Token{...}}
You can generate a credentials file containing service account using `gcloud` utility like this:
$ gcloud iam service-accounts keys create --key-file-type=json --iam-account=... credentials.json
#### Generate a cloud function invocation token using a service account credentials file:
iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
...> claims = %{"target_audience" => "https://<GCP_REGION>-<PROJECT_ID>.cloudfunctions.net/<CLOUD_FUNCTION_NAME>"}
...> Goth.Token.fetch(source: {:service_account, credentials, [claims: claims]})
{:ok, %Goth.Token{...}}
#### Generate an impersonated token using a service account credentials file:
iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
...> claims = %{"sub" => "<IMPERSONATED_ACCOUNT_EMAIL>"}
...> Goth.Token.fetch(source: {:service_account, credentials, [claims: claims]})
{:ok, %Goth.Token{...}}
#### Retrieve the token using a refresh token:
iex> credentials = "credentials.json" |> File.read!() |> Jason.decode!()
iex> Goth.Token.fetch(source: {:refresh_token, credentials})
{:ok, %Goth.Token{...}}
You can generate a credentials file containing refresh token using `gcloud` utility like this:
$ gcloud auth application-default login
#### Retrieve the token using the Google metadata server:
iex> Goth.Token.fetch(source: :metadata)
{:ok, %Goth.Token{...}}
See [Storing and retrieving instance metadata](https://cloud.google.com/compute/docs/storing-retrieving-metadata)
for more information on metadata server.
#### Passing custom Finch options
iex> Goth.Token.fetch(source: source, http_client: {:finch, pool_timeout: 5000})
{:ok, %Goth.Token{...}}
"""
@doc since: "1.3.0"
@spec fetch(keyword | map()) :: {:ok, t()} | {:error, Exception.t()}
def fetch(config)
def fetch(config) when is_list(config) do
config |> Map.new() |> fetch()
end
def fetch(config) when is_map(config) do
config
|> Map.put_new(:source, {:default, []})
|> Map.put_new(:http_client, {:finch, []})
|> request()
end
defp request(%{source: {:default, opts}} = config) do
case Goth.Config.get(:token_source) do
{:ok, :oauth_jwt} ->
{:ok, private_key} = Goth.Config.get(:private_key)
{:ok, client_email} = Goth.Config.get(:client_email)
credentials = %{
"private_key" => private_key,
"client_email" => client_email
}
request(%{config | source: {:service_account, credentials, opts}})
{:ok, :oauth_refresh} ->
{:ok, refresh_token} = Goth.Config.get(:refresh_token)
{:ok, client_id} = Goth.Config.get(:client_id)
{:ok, client_secret} = Goth.Config.get(:client_secret)
credentials = %{
"refresh_token" => refresh_token,
"client_id" => client_id,
"client_secret" => client_secret
}
request(%{config | source: {:refresh_token, credentials, opts}})
{:ok, :metadata} ->
request(%{config | source: {:metadata, opts}})
end
end
defp request(%{source: {:service_account, credentials}} = config) do
request(%{config | source: {:service_account, credentials, []}})
end
defp request(%{source: {:service_account, credentials, options}} = config)
when is_map(credentials) and is_list(options) do
url = Keyword.get(options, :url, @default_url)
claims =
Keyword.get_lazy(options, :claims, fn ->
scope = options |> Keyword.get(:scopes, @default_scopes) |> Enum.join(" ")
%{"scope" => scope}
end)
unless claims |> Map.keys() |> Enum.all?(&is_binary/1),
do: raise("expected service account claims to be a map with string keys, got a map: #{inspect(claims)}")
jwt = jwt_encode(claims, credentials)
headers = [{"content-type", "application/x-www-form-urlencoded"}]
grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
body = "grant_type=#{grant_type}&assertion=#{jwt}"
response = request(config.http_client, method: :post, url: url, headers: headers, body: body)
case handle_response(response) do
{:ok, token} ->
sub = Map.get(claims, "sub", token.sub)
scope = Map.get(claims, "scope", token.scope)
{:ok, %{token | scope: scope, sub: sub}}
{:error, error} ->
{:error, error}
end
end
defp request(%{source: {:refresh_token, credentials}} = config) do
request(%{config | source: {:refresh_token, credentials, []}})
end
defp request(%{source: {:refresh_token, credentials, options}} = config)
when is_map(credentials) and is_list(options) do
url = Keyword.get(options, :url, @default_url)
headers = [{"Content-Type", "application/x-www-form-urlencoded"}]
refresh_token = Map.fetch!(credentials, "refresh_token")
client_id = Map.fetch!(credentials, "client_id")
client_secret = Map.fetch!(credentials, "client_secret")
body =
URI.encode_query(
grant_type: "refresh_token",
refresh_token: refresh_token,
client_id: client_id,
client_secret: client_secret
)
response = request(config.http_client, method: :post, url: url, headers: headers, body: body)
handle_response(response)
end
defp request(%{source: :metadata} = config) do
request(%{config | source: {:metadata, []}})
end
defp request(%{source: {:metadata, options}} = config) when is_list(options) do
{url, audience} = metadata_options(options)
headers = [{"metadata-flavor", "Google"}]
response = request(config.http_client, method: :get, url: url, headers: headers, body: "")
case audience do
nil -> handle_response(response)
_ -> handle_jwt_response(response)
end
end
defp metadata_options(options) do
account = Keyword.get(options, :account, "default")
audience = Keyword.get(options, :audience, nil)
path = "/computeMetadata/v1/instance/service-accounts/"
base_url = Keyword.get(options, :url, "http://metadata.google.internal")
url =
case audience do
nil -> "#{base_url}#{path}#{account}/token"
audience -> "#{base_url}#{path}#{account}/identity?audience=#{audience}"
end
{url, audience}
end
defp handle_jwt_response({:ok, %{status: 200, body: body}}) do
{:ok, build_token(%{"id_token" => body})}
end
defp handle_jwt_response(response), do: handle_response(response)
defp handle_response({:ok, %{status: 200, body: body}}) when is_map(body) do
{:ok, build_token(body)}
end
defp handle_response({:ok, %{status: 200, body: body}}) do
case Jason.decode(body) do
{:ok, attrs} -> {:ok, build_token(attrs)}
{:error, reason} -> {:error, reason}
end
end
defp handle_response({:ok, response}) do
message = """
unexpected status #{response.status} from Google
#{response.body}
"""
{:error, RuntimeError.exception(message)}
end
defp handle_response({:error, exception}) do
{:error, exception}
end
defp jwt_encode(claims, %{"private_key" => private_key, "client_email" => client_email}) do
jwk = JOSE.JWK.from_pem(private_key)
header = %{"alg" => "RS256", "typ" => "JWT"}
unix_time = System.system_time(:second)
default_claims = %{
"iss" => client_email,
"aud" => "https://www.googleapis.com/oauth2/v4/token",
"exp" => unix_time + 3600,
"iat" => unix_time
}
claims = Map.merge(default_claims, claims)
JOSE.JWT.sign(jwk, header, claims) |> JOSE.JWS.compact() |> elem(1)
end
defp build_token(%{"access_token" => _} = attrs) do
%__MODULE__{
expires: System.system_time(:second) + attrs["expires_in"],
token: attrs["access_token"],
type: attrs["token_type"],
scope: attrs["scope"],
sub: attrs["sub"]
}
end
defp build_token(%{"id_token" => jwt}) when is_binary(jwt) do
%JOSE.JWT{fields: fields} = JOSE.JWT.peek_payload(jwt)
%__MODULE__{
expires: fields["exp"],
token: jwt,
type: "Bearer",
scope: fields["aud"],
sub: fields["sub"]
}
end
defp request({:finch, extra_options}, options) do
Goth.__finch__(options ++ extra_options)
end
defp request({mod, _} = config, options) when is_atom(mod) do
Goth.HTTPClient.request(config, options[:method], options[:url], options[:headers], options[:body], [])
end
defp request({fun, extra_options}, options) when is_function(fun, 1) do
fun.(options ++ extra_options)
end
# Everything below is deprecated.
alias Goth.Client
alias Goth.TokenStore
# Get a `%Goth.Token{}` for a particular `scope`. `scope` can be a single
# scope or multiple scopes joined by a space. See [OAuth 2.0 Scopes for Google APIs](https://developers.google.com/identity/protocols/googlescopes) for all available scopes.
# `sub` needs to be specified if impersonation is used to prevent cache
# leaking between users.
# ## Example
# iex> Token.for_scope("https://www.googleapis.com/auth/pubsub")
# {:ok, %Goth.Token{expires: ..., token: "...", type: "..."} }
@deprecated "Use Goth.fetch/1 instead"
def for_scope(info, sub \\ nil)
@spec for_scope(scope :: String.t(), sub :: String.t() | nil) :: {:ok, t} | {:error, any()}
def for_scope(scope, sub) when is_binary(scope) do
case TokenStore.find({:default, scope}, sub) do
:error -> retrieve_and_store!({:default, scope}, sub)
{:ok, token} -> {:ok, token}
end
end
@spec for_scope(info :: {String.t() | atom(), String.t()}, sub :: String.t() | nil) ::
{:ok, t} | {:error, any()}
def for_scope({account, scope}, sub) do
case TokenStore.find({account, scope}, sub) do
:error -> retrieve_and_store!({account, scope}, sub)
{:ok, token} -> {:ok, token}
end
end
@doc false
# Parse a successful JSON response from Google's token API and extract a `%Goth.Token{}`
def from_response_json(scope, sub \\ nil, json)
@spec from_response_json(String.t(), String.t() | nil, String.t()) :: t
def from_response_json(scope, sub, json) when is_binary(scope) do
{:ok, attrs} = json |> Jason.decode()
%__MODULE__{
token: attrs["access_token"],
type: attrs["token_type"],
scope: scope,
sub: sub,
expires: :os.system_time(:seconds) + attrs["expires_in"],
account: :default
}
end
@spec from_response_json(
{atom() | String.t(), String.t()},
String.t() | nil,
String.t()
) :: t
def from_response_json({account, scope}, sub, json) do
{:ok, attrs} = json |> Jason.decode()
%__MODULE__{
token: attrs["access_token"],
type: attrs["token_type"],
scope: scope,
sub: sub,
expires: :os.system_time(:seconds) + attrs["expires_in"],
account: account
}
end
# Retrieve a new access token from the API. This is useful for expired tokens,
# although `Goth` automatically handles refreshing tokens for you, so you should
# rarely if ever actually need to call this method manually.
@doc false
@spec refresh!(t() | {any(), any()}) :: {:ok, t()}
def refresh!(%__MODULE__{account: account, scope: scope, sub: sub}),
do: refresh!({account, scope}, sub)
def refresh!(%__MODULE__{account: account, scope: scope}), do: refresh!({account, scope})
@doc false
@spec refresh!({any(), any()}, any()) :: {:ok, t()}
def refresh!({account, scope}, sub \\ nil), do: retrieve_and_store!({account, scope}, sub)
@doc false
def queue_for_refresh(%__MODULE__{} = token) do
diff = token.expires - :os.system_time(:seconds)
if diff < 10 do
# just do it immediately
Task.async(fn ->
__MODULE__.refresh!(token)
end)
else
:timer.apply_after((diff - 10) * 1000, __MODULE__, :refresh!, [token])
end
end
defp retrieve_and_store!({account, scope}, sub) do
Client.get_access_token({account, scope}, sub: sub)
|> case do
{:ok, token} ->
TokenStore.store({account, scope}, sub, token)
{:ok, token}
other ->
other
end
end
end