Skip to content

Commit

Permalink
feat: sign in with OTP
Browse files Browse the repository at this point in the history
  • Loading branch information
zoedsoupe committed Apr 16, 2024
1 parent d84012c commit 1c664e6
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 50 deletions.
17 changes: 17 additions & 0 deletions lib/supabase/go_true.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Supabase.GoTrue do
alias Supabase.Client
alias Supabase.GoTrue.Schemas.SignInWithIdToken
alias Supabase.GoTrue.Schemas.SignInWithOauth
alias Supabase.GoTrue.Schemas.SignInWithOTP
alias Supabase.GoTrue.Schemas.SignInWithPassword
alias Supabase.GoTrue.Schemas.SignInWithSSO
alias Supabase.GoTrue.Schemas.SignUpWithPassword
Expand Down Expand Up @@ -42,6 +43,22 @@ defmodule Supabase.GoTrue do
end
end

@impl true
def sign_in_with_otp(client, credentials) when is_client(client) do
with {:ok, client} <- Client.retrieve_client(client),
{:ok, credentials} <- SignInWithOTP.parse(credentials) do
UserHandler.sign_in_with_otp(client, credentials)
end
end

@impl true
def verify_otp(client, params) when is_client(client) do
with {:ok, client} <- Client.retrieve_client(client),
{:ok, response} <- UserHandler.verify_otp(client, params) do
Session.parse(response)
end
end

@impl true
def sign_in_with_sso(client, credentials) when is_client(client) do
with {:ok, client} <- Client.retrieve_client(client),
Expand Down
18 changes: 1 addition & 17 deletions lib/supabase/go_true/schemas/admin_user_params.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Supabase.GoTrue.Schemas.AdminUserParams do
@moduledoc false

import Ecto.Changeset
import Supabase.GoTrue.Validations

@types %{
app_metadata: :map,
Expand All @@ -21,21 +22,4 @@ defmodule Supabase.GoTrue.Schemas.AdminUserParams do
|> validate_required_inclusion([:email, :phone])
|> apply_action(:parse)
end

defp validate_required_inclusion(%{valid?: false} = c, _), do: c

defp validate_required_inclusion(changeset, fields) do
if Enum.any?(fields, &present?(changeset, &1)) do
changeset
else
changeset
|> add_error(:email, "at least an email or phone is required")
|> add_error(:phone, "at least an email or phone is required")
end
end

defp present?(changeset, field) do
value = get_change(changeset, field)
value && value != ""
end
end
50 changes: 36 additions & 14 deletions lib/supabase/go_true/schemas/sign_in_request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ defmodule Supabase.GoTrue.Schemas.SignInRequest do
use Ecto.Schema

import Ecto.Changeset
import Supabase.GoTrue.Validations

alias Supabase.GoTrue.Schemas.SignInWithIdToken
alias Supabase.GoTrue.Schemas.SignInWithOTP
alias Supabase.GoTrue.Schemas.SignInWithPassword
alias Supabase.GoTrue.Schemas.SignInWithSSO

@derive Jason.Encoder
@primary_key false
embedded_schema do
field(:email, :string)
Expand All @@ -21,6 +22,10 @@ defmodule Supabase.GoTrue.Schemas.SignInRequest do
field(:id_token, :string)
field :provider_id, :string
field :domain, :string
field :create_user, :boolean
field :redirect_to, :string
field :channel, :string
field :data, :map, default: %{}
field(:code_challenge, :string)
field(:code_challenge_method, :string)

Expand All @@ -30,6 +35,17 @@ defmodule Supabase.GoTrue.Schemas.SignInRequest do
end
end

def create(%SignInWithOTP{} = signin, code_challenge, code_method) do
attrs = SignInWithOTP.to_sign_in_params(signin, code_challenge, code_method)
gotrue_meta = %__MODULE__.GoTrueMetaSecurity{captcha_token: signin.options.captcha_token}

%__MODULE__{}
|> cast(attrs, [:email, :phone, :data, :create_user, :redirect_to, :channel])
|> put_embed(:gotrue_meta_security, gotrue_meta, required: true)
|> validate_required_inclusion([:email, :phone])
|> apply_action(:insert)
end

def create(%SignInWithSSO{} = signin, code_challenge, code_method) do
attrs = SignInWithSSO.to_sign_in_params(signin, code_challenge, code_method)
gotrue_meta = %__MODULE__.GoTrueMetaSecurity{captcha_token: signin.options.captcha_token}
Expand All @@ -41,6 +57,17 @@ defmodule Supabase.GoTrue.Schemas.SignInRequest do
|> apply_action(:insert)
end

def create(%SignInWithOTP{} = signin) do
attrs = SignInWithOTP.to_sign_in_params(signin)
gotrue_meta = %__MODULE__.GoTrueMetaSecurity{captcha_token: signin.options.captcha_token}

%__MODULE__{}
|> cast(attrs, [:email, :phone, :data, :create_user, :redirect_to, :channel])
|> put_embed(:gotrue_meta_security, gotrue_meta, required: true)
|> validate_required_inclusion([:email, :phone])
|> apply_action(:insert)
end

def create(%SignInWithSSO{} = signin) do
attrs = SignInWithSSO.to_sign_in_params(signin)
gotrue_meta = %__MODULE__.GoTrueMetaSecurity{captcha_token: signin.options.captcha_token}
Expand Down Expand Up @@ -75,20 +102,15 @@ defmodule Supabase.GoTrue.Schemas.SignInRequest do
|> apply_action(:insert)
end

defp validate_required_inclusion(%{valid?: false} = c, _), do: c
defimpl Jason.Encoder, for: __MODULE__ do
alias Supabase.GoTrue.Schemas.SignInRequest

defp validate_required_inclusion(changeset, fields) do
if Enum.any?(fields, &present?(changeset, &1)) do
changeset
else
changeset
|> add_error(:email, "at least an email or phone is required")
|> add_error(:phone, "at least an email or phone is required")
def encode(%SignInRequest{} = request, opts) do
request
|> Map.from_struct()
|> Map.filter(fn {_k, v} -> not is_nil(v) end)
|> Map.delete(:redirect_to)
|> Jason.Encode.map(opts)
end
end

defp present?(changeset, field) do
value = get_change(changeset, field)
value && value != ""
end
end
96 changes: 96 additions & 0 deletions lib/supabase/go_true/schemas/sign_in_with_otp.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule Supabase.GoTrue.Schemas.SignInWithOTP do
@moduledoc false

use Supabase, :schema

import Supabase.GoTrue.Validations

@type options :: %__MODULE__.Options{
data: map(),
email_redirect_to: String.t(),
captcha_token: String.t(),
channel: String.t(),
should_create_user: boolean()
}

@type t :: %__MODULE__{
email: String.t(),
phone: String.t(),
options: options
}

@primary_key false
embedded_schema do
field :email, :string
field :phone, :string

embeds_one :options, Options, primary_key: false do
field(:data, :map)
field(:email_redirect_to, :string)
field(:captcha_token, :string)
field(:channel, :string, default: "sms")
field(:should_create_user, :boolean, default: true)
end
end

def to_sign_in_params(%__MODULE__{email: email} = signin, code_challenge, code_method) when not is_nil(email) do
signin
|> Map.take([:email])
|> Map.put(:data, signin.options.data)
|> Map.put(:captcha_token, signin.options.captcha_token)
|> Map.put(:create_user, signin.options.should_create_user)
|> Map.put(:redirect_to, signin.options.email_redirect_to)
|> Map.merge(%{code_challange: code_challenge, code_challenge_method: code_method})
end

def to_sign_in_params(%__MODULE__{phone: phone} = signin, code_challenge, code_method) when not is_nil(phone) do
signin
|> Map.take([:phone])
|> Map.put(:data, signin.options.data)
|> Map.put(:captcha_token, signin.options.captcha_token)
|> Map.put(:create_user, signin.options.should_create_user)
|> Map.put(:channel, signin.options.channel)
|> Map.merge(%{code_challange: code_challenge, code_challenge_method: code_method})
end

def to_sign_in_params(%__MODULE__{email: email} = signin) when not is_nil(email) do
signin
|> Map.take([:email])
|> Map.put(:data, signin.options.data)
|> Map.put(:captcha_token, signin.options.captcha_token)
|> Map.put(:create_user, signin.options.should_create_user)
|> Map.put(:redirect_to, signin.options.email_redirect_to)
end

def to_sign_in_params(%__MODULE__{phone: phone} = signin) when not is_nil(phone) do
signin
|> Map.take([:phone])
|> Map.put(:data, signin.options.data)
|> Map.put(:captcha_token, signin.options.captcha_token)
|> Map.put(:create_user, signin.options.should_create_user)
|> Map.put(:channel, signin.options.channel)
end

def parse(attrs) do
%__MODULE__{}
|> cast(attrs, ~w[email phone]a)
|> validate_required_inclusion(~w[email phone]a)
|> cast_embed(:options, with: &options_changeset/2, required: false)
|> maybe_put_default_options()
|> apply_action(:parse)
end

defp maybe_put_default_options(%{valid?: false} = c), do: c

defp maybe_put_default_options(changeset) do
if get_embed(changeset, :options) do
changeset
else
put_embed(changeset, :options, %__MODULE__.Options{})
end
end

defp options_changeset(options, attrs) do
cast(options, attrs, ~w[data email_redirect_to channel should_create_user captcha_token]a)
end
end
19 changes: 2 additions & 17 deletions lib/supabase/go_true/schemas/sign_in_with_sso.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ defmodule Supabase.GoTrue.Schemas.SignInWithSSO do

use Supabase, :schema

import Supabase.GoTrue.Validations

@type options :: %__MODULE__.Options{
redirect_to: String.t(),
captcha_token: String.t()
Expand Down Expand Up @@ -59,21 +61,4 @@ defmodule Supabase.GoTrue.Schemas.SignInWithSSO do
defp options_changeset(options, attrs) do
cast(options, attrs, ~w[redirect_to captcha_token]a)
end

defp validate_required_inclusion(%{valid?: false} = c, _), do: c

defp validate_required_inclusion(changeset, fields) do
if Enum.any?(fields, &present?(changeset, &1)) do
changeset
else
changeset
|> add_error(:provider_id, "at least an provider_id or domain is required")
|> add_error(:domain, "at least an provider_id or domain is required")
end
end

defp present?(changeset, field) do
value = get_change(changeset, field)
value && value != ""
end
end
86 changes: 86 additions & 0 deletions lib/supabase/go_true/schemas/verify_otp.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule Supabase.GoTrue.Schemas.VerifyOTP do
@moduledoc false

use Supabase, :schema

@type options :: %{redirect_to: String.t(), captcha_token: String.t()}
@type mobile :: %{phone: String.t(), token: String.t(), type: String.t(), options: options}
@type email :: %{email: String.t(), token: String.t(), type: String.t(), options: options}
@type token_hash :: %{token_hash: String.t(), type: String.t(), options: options}
@type t :: mobile | email | token_hash

def to_request(%{} = params) do
with {:ok, data} <- parse(params) do
captcha_token = get_in(data, [:options, :captcha_token])
{:ok, Map.put(data, :go_true_security, %{captcha_token: captcha_token})}
end
end

@mobile_otp_types ~w[sms phone_change]a
@email_otp_types ~w[signup invite magiclink recovery email_change email]a

@options_types %{redirect_to: :string, captcha_token: :string}

@mobile_types %{
phone: :string,
token: :string,
type: Ecto.ParameterizedType.init(Ecto.Enum, values: @mobile_otp_types),
options: :map
}

@email_types %{
email: :string,
token: :string,
type: Ecto.ParameterizedType.init(Ecto.Enum, values: @email_otp_types),
options: :map
}

@token_hash_types %{
token_hash: :string,
type: Ecto.ParameterizedType.init(Ecto.Enum, values: @email_otp_types),
options: :map
}

def parse(%{phone: _} = attrs) do
{%{}, @mobile_types}
|> cast(attrs, [:phone, :token, :type, :options])
|> validate_required([:phone, :token, :type])
|> options_changeset()
|> apply_action(:parse)
end

def parse(%{email: _} = attrs) do
{%{}, @email_types}
|> cast(attrs, [:email, :token, :type, :options])
|> validate_required([:email, :token, :type])
|> options_changeset()
|> apply_action(:parse)
end

def parse(%{token_hash: _} = attrs) do
{%{}, @token_hash_types}
|> cast(attrs, [:token_hash, :type, :options])
|> validate_required([:token_hash, :type])
|> options_changeset()
|> apply_action(:parse)
end

defp options_changeset(%Ecto.Changeset{valid?: false} = changeset), do: changeset

defp options_changeset(%Ecto.Changeset{} = changeset) do
if options = get_change(changeset, :options) do
{%{}, @options_types}
|> cast(options, Map.keys(@options_types))
|> apply_action(:parse)
|> case do
{:ok, option} -> put_change(changeset, :options, option)
{:error, error_changeset} ->
for {field, {err, info}} <- error_changeset.errors, reduce: changeset do
changeset -> add_error(changeset, "options.#{field}", err, info)
end
end
else
changeset
end
end
end
Loading

0 comments on commit 1c664e6

Please sign in to comment.