lib/auth/signin/google/index.ex

defmodule Rivet.Auth.Signin.Google do
  @moduledoc """
  Google login scheme
  """
  alias Rivet.Ident
  alias Rivet.Auth

  # TODO:
  # - pull imageUrl (tiff?) and converts it / uploads it into user's profile

  ##############################################################################
  # note: this is different from `Auth.Domain.result()` typedef, in it can have any
  # value with :ok, where the second part of Auth.Domain.result() is always an Auth.Domain
  @spec check(String.t(), map()) :: Auth.Domain.result()
  def check(hostname, args, type \\ :authed)

  def check(hostname, %{"data" => %{"auth" => token}}, type) when is_binary(hostname) do
    verify_google_signature(token)
    |> find_or_create_user(hostname, type)
    |> Auth.Signin.post_signin()
  end

  def check(_, _, _),
    do:
      {:error,
       %Auth.Domain{
         error: "Signup Failed",
         log: "auth signup failed, invalid arguments from client"
       }}

  ##############################################################################
  @spec verify_google_signature(String.t()) ::
          {:ok, %{payload: map(), header: map(), token: String.t()}}
          | {:error, Auth.Domain.t()}
  defp verify_google_signature(token) do
    case Auth.Token.extract(token, :header) do
      {:ok, header} ->
        # from the unverified header we pull the google key, and index that against
        # the keys we have from google, to get the pubkey (jwk) that will check the
        # signature of this jwt
        google_keys = Auth.Signin.Google.KeyManager.get_keys()
        jwk = Map.get(google_keys, header["kid"])
        auth = %Auth.Domain{token: token}

        case JOSE.JWT.verify_strict(jwk, ["RS256"], token) do
          {true, %JOSE.JWT{fields: fields}, %JOSE.JWS{} = jws} ->
            {:ok, %{payload: fields, header: Map.from_struct(jws), token: token}}

          {false, %JOSE.JWT{fields: %{"email" => email}}, %JOSE.JWS{}} ->
            {:error, %Auth.Domain{auth | log: "Unable to verify google token for #{email}"}}

          _ ->
            {:error, %Auth.Domain{auth | log: "Unable to verify google JWT"}}
        end

      {:error, reason} ->
        {:error, %Auth.Domain{log: reason}}
    end
  end

  ##############################################################################
  defp find_or_create_user(
         {:ok, %{payload: %{"email_verified" => true} = payload}},
         hostname,
         type
       ) do
    payload
    |> find_user(hostname, type)
    |> update_user_avatar(payload["picture"])
  end

  defp find_or_create_user(pass, _, _), do: pass

  ###############################################################################
  # defp update_user_avatar(
  # {:ok, %Auth.Domain{status: :authed, user: %Ident.User{} = _}} = pass,
  # _picurl
  # ) do
  # IO.puts("Need to get avatar")
  ## 1. see if user has avatar
  ## 2. if not, in async process pull URL and submit as avatar
  # pass
  # end

  defp update_user_avatar(pass, _), do: pass

  ###############################################################################
  defp find_user(%{"sub" => ident} = params, hostname, type) do
    case Ident.UserIdent.Lib.get("google", ident) do
      {:ok, user_ident} -> Ident.User.one(id: user_ident.user_id) |> check_user_allowed(hostname)
      _ -> find_user_by_email(params, hostname, type)
    end
  end

  defp find_user(_, _, _),
    do: {:error, "Unrecognized payload values, cannot continue; missing 'sub'"}

  ###############################################################################
  defp find_user_by_email(%{"email" => email, "sub" => ident} = params, hostname, type) do
    case Ident.Email.one([address: email], [:user]) do
      {:ok, %Ident.Email{user: %Ident.User{} = u}} ->
        {:ok, u}
        |> check_user_allowed(hostname)
        |> add_ident(ident)

      _ ->
        create_user(params, hostname, type)
    end
  end

  defp google_allowed?(user) do
    case get_in(user.settings, ["authAllowed", "google"]) do
      nil ->
        # add google in if it wasn't set at all
        settings =
          case user.settings do
            %{"authAllowed" => _} -> put_in(user.settings, ["authAllowed", "google"], true)
            _ -> Map.put(user.settings, "authAllowed", %{"google" => true})
          end

        with {:error, err} <- Ident.User.update(user, %{settings: settings}) do
          IO.inspect(err, label: "Unexpected error updating user settings on signin")
        end

        true

      value ->
        value
    end
  end

  defp check_user_allowed({:ok, %Ident.User{} = user}, hostname) do
    if google_allowed?(user) and Ident.User.enabled?(user) do
      {:ok, %Auth.Domain{hostname: hostname, status: :authed, user: user}}
    else
      {:error,
       %Auth.Domain{
         log: "Federated signin when user already exists",
         error:
           "The user has already signed in, but has not authorized google logins. You can try to reset your password."
       }}
    end
  end

  defp check_user_allowed(_, _),
    do: {:error, %Auth.Domain{log: "User identified but unable to load"}}

  ##############################################################################
  defp create_user(params, hostname, type) do
    fedid = payload_to_fedid(params)

    Rivet.Auth.Signin.create_user(fedid.handle, fedid.email.address, hostname, type, %{
      fedid: fedid,
      name: params["name"],
      settings: %{"authAllowed" => %{"google" => true}}
    })
    |> add_ident(params["sub"])
  end

  # ##############################################################################
  defp add_ident({:ok, %Auth.Domain{user: %Ident.User{} = u}} = pass, ident) do
    with {:ok, _} <- Ident.UserIdent.Lib.put(u, "google", ident) do
      pass
    end
  end

  defp add_ident(pass, _), do: pass

  ##############################################################################
  #  defp link_factor({:ok, %Ident.User{} = user}) do
  #
  #   {
  #   "iss": "https://accounts.google.com",
  #   "nbf": 1111111115,
  #   "aud": "311111111124-ecb8lih2719bbpe1tv1itiq003p7pa4h.apps.googleusercontent.com",
  #   "sub": "111111111111111111354",
  #   "hd": "cdomain",
  #   "email": "user@domain",
  #   "email_verified": true,
  #   "azp": "310dfasdfasdf-ecb7lih2717bbpe1td3itiq003e7pn4h.apps.googleusercontent.com",
  #   "name": "Joe Friday",
  #   "picture": "https://lh3.googleusercontent.com/a/AEwFTp5SjTOtm4RJ176koDsSEmkXv6o_7hTf1ueeZBOk=s96-c",
  #   "given_name": "Joe",
  #   "family_name": "Friday",
  #   "iat": 1111111975,
  #   "exp": 1111111575,
  #   "jti": "casfdasdfasdfasdfasdf"
  # }
  defp payload_to_fedid(payload) do
    # could update locale if we have it here in settings
    %Ident.Factor.FedId{
      name: payload["name"],
      handle: Ident.Handle.Lib.gen_good_handle(payload["email"]),
      email: %Ident.Factor.FedId.Email{
        address: payload["email"],
        verified: true
      },
      phone: nil,
      provider: %Ident.Factor.FedId.Provider{
        type: :google,
        sub: payload["sub"],
        jti: payload["jti"],
        iss: payload["iss"],
        iat: payload["iat"],
        exp: payload["exp"],
        azp: payload["azp"],
        aud: payload["aud"],
        token: payload["token"]
      }
    }
  end
end