defmodule Rivet.Auth.Signin.Google.AuthKeys do @moduledoc """ Syntactic sugar to wrap the HTTP call in requesting the keys. Questionable if this is worthwhile, but it's working now... """ def get!(path) do {:ok, 200, headers, body} = :hackney.request(:get, "https://www.googleapis.com/oauth2/v1/#{path}", [], "", recv_timeout: 500, with_body: true ) %{headers: headers, body: Jason.decode!(body)} end end defmodule Rivet.Auth.Signin.Google.KeyManager do @moduledoc """ This module runs as a separate process which keeps google's oauth public keys current, as they change weekly. This process inspects the expiration date (which google says should be correct) and updates at least by then, but does not wait longer than 1 day. """ require Logger use GenServer ############################################################################## # Google Manager service logic @doc """ External interface to request a current copy of the keys for google ``` iex> dict = Rivet.Auth.Signin.Google.KeyManager.get_keys() iex> is_map(dict) true ``` """ def get_keys() do GenServer.call(:google_key_manager, :get_keys) end # Internal method that handles calling Google to get current keys, based on # an interval derived from the google request expires header (this is their # recommendation) defp run_interval(state) do Logger.info("Refreshing Google Auth Certificates") result = Rivet.Auth.Signin.Google.AuthKeys.get!("certs") now_t = System.os_time(:second) exp_t = case Enum.find(result.headers, fn {k, _} -> String.downcase(k) == "expires" end) do {_, expires} -> Timex.parse!(expires, "{RFC1123}") |> Timex.to_unix() nil -> Logger.error("Unexpected: google auth did not respond with an expires") Logger.error("Response Headers: #{inspect(result.headers)}") # giving a 1-min expiration will force a recheck in 1 min now_t + 60 end # give ourselves a buffer, with some imperative assertions... diff_t = exp_t - now_t # greater than a day? Just refresh it in a day interval = if diff_t > 86400 do 86400 else # wow, it expires today! worst case, refresh in five mins, unless that is too far? if diff_t - 300 <= 0 do 300 else # trim off 5 mins diff_t - 300 end end # update the interval state = Map.put(state, :interval, interval * 1000) # update the key set Map.put(state, :keys, result.body |> decode_keys) end # NOTE: try using JOSE's JWK instead of the PEM one # Internal method to convert the keys from PEM format into what we are using. # # Google also provides a JSON format other than this PEM, but we are having # problems getting it to work w/JOSE. The PEM works. defp decode_keys(keys) do Enum.reduce(keys, %{}, fn {k, v}, acc -> Map.put(acc, k, JOSE.JWK.from_pem(v)) end) end ############################################################################## # general GenServer things after this # standard way for us to request a future interval callback defp queue_interval(interval) do Process.send_after(:google_key_manager, :interval, interval) end def start_link(state) do GenServer.start_link(__MODULE__, state, name: :google_key_manager) end def init(state) do Map.merge(state, %{interval: 60_000, keys: %{}}) queue_interval(0) {:ok, state} end def handle_info(:interval, state = %{interval: _interval}) do state = %{interval: interval} = run_interval(state) queue_interval(interval) {:noreply, state} end def handle_call(:get_keys, _from, state = %{keys: keys}) do {:reply, keys, state} end end