Current section
Files
Jump to
Current section
Files
lib/supabase/auth/mfa.ex
defmodule Supabase.Auth.MFA do
@moduledoc """
Multi-Factor Authentication (MFA) operations for Supabase Auth.
This module provides functions to manage MFA factors including TOTP (Time-based One-Time Password),
Phone (SMS/WhatsApp), and WebAuthn authentication methods.
## Factor Types
- **TOTP**: Time-based one-time passwords (authenticator apps like Google Authenticator, Authy)
- **Phone**: SMS or WhatsApp-based verification codes
- **WebAuthn**: Hardware security keys and biometric authentication
## Typical MFA Flow
1. **Enroll**: User enrolls a new MFA factor using `enroll/3`
2. **Challenge**: System issues a challenge for verification using `challenge/4`
3. **Verify**: User provides response to complete verification using `verify/5`
Alternatively, for TOTP factors, you can use `challenge_and_verify/4` to combine steps 2 and 3.
## Authenticator Assurance Levels (AAL)
- **AAL1**: Single-factor authentication (password, magic link, OAuth)
- **AAL2**: Multi-factor authentication (at least one MFA factor verified)
After successful MFA verification, the session will be upgraded to AAL2.
## Examples
# Enroll TOTP factor
{:ok, factor} = Supabase.Auth.MFA.enroll(client, session, %{
factor_type: :totp,
friendly_name: "My Authenticator"
})
# Display QR code to user (factor.totp.qr_code contains SVG data)
qr_code_svg = factor.totp.qr_code
# Enroll Phone factor
{:ok, phone_factor} = Supabase.Auth.MFA.enroll(client, session, %{
factor_type: :phone,
phone: "+1234567890",
friendly_name: "My Phone"
})
# Challenge and verify in one step (TOTP only)
{:ok, new_session} = Supabase.Auth.MFA.challenge_and_verify(
client,
session,
factor.id,
"123456"
)
# Or use separate challenge and verify steps
{:ok, challenge} = Supabase.Auth.MFA.challenge(client, session, factor.id, %{})
{:ok, new_session} = Supabase.Auth.MFA.verify(
client,
session,
factor.id,
challenge.id,
%{code: "123456"}
)
# List all factors
{:ok, %{all: factors, totp: totp_factors}} = Supabase.Auth.MFA.list_factors(client, session)
# Get current authentication level
{:ok, %{current_level: :aal2}} =
Supabase.Auth.MFA.get_authenticator_assurance_level(client, session)
# Unenroll a factor
{:ok, %{id: factor_id}} = Supabase.Auth.MFA.unenroll(client, session, factor.id)
## Related Modules
- `Supabase.Auth.MFA.Behaviour` - Type definitions for MFA operations
- `Supabase.Auth.Session` - Session containing access tokens
- `Supabase.Auth.User` - User profile with factors
"""
@behaviour Supabase.Auth.MFA.Behaviour
alias Supabase.Auth.MFA.Behaviour, as: MFABehaviour
alias Supabase.Auth.MFAHandler
alias Supabase.Auth.Schemas.MFA.ChallengeAndVerifyParams
alias Supabase.Auth.Schemas.MFA.ChallengeParams
alias Supabase.Auth.Schemas.MFA.EnrollParams
alias Supabase.Auth.Schemas.MFA.VerifyParams
alias Supabase.Auth.Session
alias Supabase.Auth.User
alias Supabase.Client
@doc """
Enrolls a new MFA factor for the authenticated user.
The enrollment process differs based on the factor type:
- **TOTP**: Returns QR code, secret, and URI for scanning into authenticator apps
- **Phone**: Returns the enrolled phone number
- **WebAuthn**: Returns the factor ID for WebAuthn credential registration
## Parameters
* `client` - The Supabase client
* `session` - Active user session containing access token
* `params` - Factor enrollment parameters (map with factor_type and type-specific fields):
* For TOTP: `%{factor_type: :totp, friendly_name: "...", issuer: "..."}`
* For Phone: `%{factor_type: :phone, phone: "+1234567890", friendly_name: "..."}`
* For WebAuthn: `%{factor_type: :webauthn, friendly_name: "..."}`
## Returns
* `{:ok, factor}` - Successfully enrolled factor with type-specific data:
* TOTP: includes `totp` field with `qr_code`, `secret`, and `uri`
* Phone: includes `phone` field with E.164 formatted number
* WebAuthn: basic factor information only
* `{:error, error}` - Enrollment failed
## Examples
# Enroll TOTP factor
iex> Supabase.Auth.MFA.enroll(client, session, %{
...> factor_type: :totp,
...> friendly_name: "My Authenticator"
...> })
{:ok, %{
id: "factor-uuid",
factor_type: :totp,
friendly_name: "My Authenticator",
status: :unverified,
totp: %{
qr_code: "data:image/svg+xml;utf-8,...",
secret: "SECRET123",
uri: "otpauth://totp/..."
}
}}
# Enroll Phone factor
iex> Supabase.Auth.MFA.enroll(client, session, %{
...> factor_type: :phone,
...> phone: "+1234567890"
...> })
{:ok, %{
id: "factor-uuid",
factor_type: :phone,
status: :unverified,
phone: "+1234567890"
}}
"""
@spec enroll(Client.t(), Session.t(), map()) ::
{:ok, MFABehaviour.enroll_response()} | {:error, term()}
def enroll(%Client{} = client, %Session{} = session, %{factor_type: _} = params) do
with {:ok, validated_params} <- EnrollParams.parse(params),
{:ok, response} <- MFAHandler.enroll(client, session.access_token, validated_params) do
parse_factor(response.body)
end
end
@doc """
Creates a challenge for an MFA factor.
The challenge must be verified using `verify/5` with the appropriate response.
Different factor types require different challenge parameters:
- **TOTP**: No additional parameters needed (empty map)
- **Phone**: Requires `channel` (`:sms` or `:whatsapp`)
- **WebAuthn**: Requires `webauthn` map with `rp_id` and optional `rp_origins`
## Parameters
* `client` - The Supabase client
* `session` - Active user session
* `factor_id` - ID of the factor to challenge
* `params` - Challenge parameters (type-specific):
* For TOTP: `%{}` (empty map)
* For Phone: `%{channel: :sms}` or `%{channel: :whatsapp}`
* For WebAuthn: `%{webauthn: %{rp_id: "example.com", rp_origins: ["https://example.com"]}}`
## Returns
* `{:ok, challenge}` - Challenge created with:
* `id` - Challenge ID for verification
* `type` - Factor type
* `expires_at` - Unix timestamp when challenge expires
* `webauthn` - WebAuthn credential options (WebAuthn only)
* `{:error, error}` - Challenge creation failed
## Examples
# TOTP challenge
iex> Supabase.Auth.MFA.challenge(client, session, totp_factor_id, %{})
{:ok, %{id: "challenge-id", type: :totp, expires_at: 1234567890}}
# Phone challenge via SMS
iex> Supabase.Auth.MFA.challenge(client, session, phone_factor_id, %{channel: :sms})
{:ok, %{id: "challenge-id", type: :phone, expires_at: 1234567890}}
# WebAuthn challenge
iex> Supabase.Auth.MFA.challenge(client, session, webauthn_factor_id, %{
...> webauthn: %{rp_id: "example.com"}
...> })
{:ok, %{
id: "challenge-id",
type: :webauthn,
expires_at: 1234567890,
webauthn: %{type: "create", credential_options: %{...}}
}}
"""
@spec challenge(Client.t(), Session.t(), String.t(), map()) ::
{:ok, MFABehaviour.challenge_response() | MFABehaviour.webauthn_challenge_response()}
| {:error, term()}
def challenge(%Client{} = client, %Session{} = session, factor_id, params) when is_binary(factor_id) do
with {:ok, validated_params} <- ChallengeParams.parse(params),
{:ok, response} <-
MFAHandler.challenge(client, session.access_token, factor_id, validated_params) do
parse_challenge_response(response.body)
end
end
@doc """
Verifies an MFA challenge with the user's response.
Returns a new session with elevated authentication assurance level (AAL2).
The verification parameters differ based on factor type:
- **TOTP/Phone**: Provide the 6-digit code
- **WebAuthn**: Provide the WebAuthn credential response
## Parameters
* `client` - The Supabase client
* `session` - Active user session
* `factor_id` - ID of the challenged factor
* `challenge_id` - ID of the challenge to verify
* `params` - Verification parameters (type-specific):
* For TOTP/Phone: `%{code: "123456"}`
* For WebAuthn: `%{webauthn: %{type: "...", rp_id: "...", credential_response: {...}}}`
## Returns
* `{:ok, session}` - New session with AAL2 authentication including:
* `access_token` - New JWT token with elevated AAL
* `refresh_token` - New refresh token
* `user` - Updated user object
* `expires_in` - Token expiration time
* `{:error, error}` - Verification failed (invalid code, expired challenge, etc.)
## Examples
# Verify TOTP code
iex> Supabase.Auth.MFA.verify(client, session, factor_id, challenge_id, %{code: "123456"})
{:ok, %Supabase.Auth.Session{
access_token: "eyJhbGci...",
user: %Supabase.Auth.User{...}
}}
# Verify Phone code
iex> Supabase.Auth.MFA.verify(client, session, factor_id, challenge_id, %{code: "654321"})
{:ok, %Supabase.Auth.Session{...}}
# Verify WebAuthn credential
iex> Supabase.Auth.MFA.verify(client, session, factor_id, challenge_id, %{
...> webauthn: %{
...> type: "create",
...> rp_id: "example.com",
...> credential_response: credential
...> }
...> })
{:ok, %Supabase.Auth.Session{...}}
"""
@spec verify(Client.t(), Session.t(), String.t(), String.t(), map()) ::
{:ok, Session.t()} | {:error, term()}
def verify(%Client{} = client, %Session{} = session, factor_id, challenge_id, params)
when is_binary(factor_id) and is_binary(challenge_id) do
with {:ok, validated_params} <- VerifyParams.parse(params),
{:ok, response} <-
MFAHandler.verify(
client,
session.access_token,
factor_id,
challenge_id,
validated_params
) do
Session.parse(response.body)
end
end
@doc """
Removes an MFA factor from the user's account.
The factor must be verified before it can be unenrolled. This operation
removes the factor permanently and cannot be undone.
## Parameters
* `client` - The Supabase client
* `session` - Active user session
* `factor_id` - ID of the factor to remove
## Returns
* `{:ok, %{id: factor_id}}` - Factor successfully removed
* `{:error, error}` - Unenrollment failed
## Examples
iex> Supabase.Auth.MFA.unenroll(client, session, "factor-uuid")
{:ok, %{id: "factor-uuid"}}
"""
@spec unenroll(Client.t(), Session.t(), String.t()) ::
{:ok, %{id: String.t()}} | {:error, term()}
def unenroll(%Client{} = client, %Session{} = session, factor_id) when is_binary(factor_id) do
with {:ok, response} <- MFAHandler.unenroll(client, session.access_token, factor_id) do
{:ok, %{id: response.body["id"]}}
end
end
@doc """
Combines challenge and verify operations in a single call (TOTP only).
This is a convenience function for TOTP factors where the code is immediately
available from the user's authenticator app. It internally creates a challenge
and immediately verifies it with the provided code.
## Parameters
* `client` - The Supabase client
* `session` - Active user session
* `factor_id` - ID of the TOTP factor
* `code` - The 6-digit TOTP code from the authenticator app
## Returns
* `{:ok, session}` - New session with AAL2 authentication
* `{:error, error}` - Verification failed
## Examples
iex> Supabase.Auth.MFA.challenge_and_verify(client, session, factor_id, "123456")
{:ok, %Supabase.Auth.Session{...}}
## Note
This function only works with TOTP factors. For Phone or WebAuthn factors,
use separate `challenge/4` and `verify/5` calls to handle the asynchronous
nature of those verification methods.
"""
@spec challenge_and_verify(Client.t(), Session.t(), String.t(), String.t()) ::
{:ok, Session.t()} | {:error, term()}
def challenge_and_verify(%Client{} = client, %Session{} = session, factor_id, code)
when is_binary(factor_id) and is_binary(code) do
with {:ok, _validated} <- ChallengeAndVerifyParams.parse(%{code: code}),
{:ok, challenge_response} <- challenge(client, session, factor_id, %{}) do
verify(client, session, factor_id, challenge_response.id, %{code: code})
end
end
@doc """
Lists all MFA factors for the authenticated user.
Returns factors organized by type for easy filtering. Only verified factors
are included in the type-specific lists (`:totp`, `:phone`, `:webauthn`),
while the `:all` list includes both verified and unverified factors.
## Parameters
* `client` - The Supabase client
* `session` - Active user session
## Returns
* `{:ok, factors_map}` - Map with keys:
* `:all` - All factors (verified and unverified)
* `:totp` - Verified TOTP factors only
* `:phone` - Verified Phone factors only
* `:webauthn` - Verified WebAuthn factors only
* `{:error, error}` - Failed to retrieve factors
## Examples
iex> {:ok, factors} = Supabase.Auth.MFA.list_factors(client, session)
iex> length(factors.all)
3
iex> length(factors.totp)
2
iex> Enum.map(factors.totp, & &1.friendly_name)
["My Phone", "Work Authenticator"]
"""
@spec list_factors(Client.t(), Session.t()) ::
{:ok, MFABehaviour.factors_list()} | {:error, term()}
def list_factors(%Client{} = _client, %Session{} = session) do
factors = session.user.factors || []
parsed_factors =
factors
|> Enum.map(&parse_factor_from_user/1)
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(fn {:ok, factor} -> factor end)
totp_factors =
Enum.filter(parsed_factors, fn f ->
f.factor_type == :totp and f.status == :verified
end)
phone_factors =
Enum.filter(parsed_factors, fn f ->
f.factor_type == :phone and f.status == :verified
end)
webauthn_factors =
Enum.filter(parsed_factors, fn f ->
f.factor_type == :webauthn and f.status == :verified
end)
{:ok,
%{
all: parsed_factors,
totp: totp_factors,
phone: phone_factors,
webauthn: webauthn_factors
}}
end
@doc """
Gets the current and next possible authenticator assurance levels.
AAL (Authenticator Assurance Level) indicates the strength of authentication:
- **AAL1**: Single-factor authentication (password, magic link, OAuth)
- **AAL2**: Multi-factor authentication (at least one verified MFA factor)
This function extracts AAL information from the session's JWT claims without
making an API call. It also determines the next achievable AAL based on the
user's enrolled factors.
## Parameters
* `client` - The Supabase client
* `session` - Active user session
## Returns
* `{:ok, aal_info}` - Map with:
* `:current_level` - Current AAL (`:aal1`, `:aal2`, or `nil`)
* `:next_level` - Next achievable AAL (`:aal1`, `:aal2`, or `nil`)
* `:current_authentication_methods` - List of authentication method references from JWT `amr` claim
* `{:error, error}` - Failed to parse AAL information
## Examples
# User with password authentication only (no MFA)
iex> Supabase.Auth.MFA.get_authenticator_assurance_level(client, session)
{:ok, %{
current_level: :aal1,
next_level: :aal2,
current_authentication_methods: ["password"]
}}
# User with verified MFA factor
iex> Supabase.Auth.MFA.get_authenticator_assurance_level(client, session_after_mfa)
{:ok, %{
current_level: :aal2,
next_level: :aal2,
current_authentication_methods: ["password", "totp"]
}}
## Note
This function does not make an HTTP request. It decodes the JWT token from
the session to extract AAL claims.
"""
@spec get_authenticator_assurance_level(Client.t(), Session.t()) ::
{:ok, MFABehaviour.aal_response()} | {:error, term()}
def get_authenticator_assurance_level(%Client{} = _client, %Session{} = session) do
with {:ok, claims} <- decode_jwt_claims(session.access_token) do
current_level = parse_aal_level(claims["aal"])
amr = claims["amr"] || []
# Determine next level based on user's factors
has_verified_factors =
Enum.any?(session.user.factors, fn factor -> factor.status == :verified end)
next_level =
cond do
has_verified_factors and current_level == :aal1 -> :aal2
has_verified_factors -> :aal2
true -> :aal1
end
{:ok,
%{
current_level: current_level,
next_level: next_level,
current_authentication_methods: amr
}}
end
end
# Private helper functions
@spec parse_factor(map()) :: {:ok, MFABehaviour.factor()} | {:error, term()}
defp parse_factor(%{"id" => id, "type" => type} = data) do
factor_type = parse_factor_type(type)
status = parse_factor_status(data["status"])
base_factor = %{
id: id,
friendly_name: data["friendly_name"],
factor_type: factor_type,
status: status,
created_at: data["created_at"],
updated_at: data["updated_at"],
last_challenged_at: data["last_challenged_at"]
}
case factor_type do
:totp ->
{:ok,
Map.put(base_factor, :totp, %{
qr_code: data["totp"]["qr_code"],
secret: data["totp"]["secret"],
uri: data["totp"]["uri"]
})}
:phone ->
{:ok, Map.put(base_factor, :phone, data["phone"])}
:webauthn ->
{:ok, base_factor}
_ ->
{:error, :unknown_factor_type}
end
end
defp parse_factor_from_user(%User.Factor{} = factor) do
base = %{
id: factor.id,
friendly_name: factor.friendly_name,
factor_type: factor.factor_type,
status: factor.status,
created_at: factor.created_at,
updated_at: factor.updated_at,
last_challenged_at: nil
}
{:ok, base}
end
@spec parse_challenge_response(map()) ::
{:ok, MFABehaviour.challenge_response() | MFABehaviour.webauthn_challenge_response()}
| {:error, term()}
defp parse_challenge_response(%{"id" => id, "type" => type, "expires_at" => expires_at} = data) do
factor_type = parse_factor_type(type)
base_response = %{
id: id,
type: factor_type,
expires_at: expires_at
}
case {factor_type, data["webauthn"]} do
{:webauthn, webauthn} when not is_nil(webauthn) ->
{:ok, Map.put(base_response, :webauthn, webauthn)}
_ ->
{:ok, base_response}
end
end
defp parse_factor_type("totp"), do: :totp
defp parse_factor_type("phone"), do: :phone
defp parse_factor_type("webauthn"), do: :webauthn
defp parse_factor_type(_), do: :unknown
defp parse_factor_status("verified"), do: :verified
defp parse_factor_status("unverified"), do: :unverified
defp parse_factor_status(_), do: :unknown
defp parse_aal_level("aal1"), do: :aal1
defp parse_aal_level("aal2"), do: :aal2
defp parse_aal_level(_), do: nil
defp decode_jwt_claims(token) do
# JWT structure: header.payload.signature
# We only need the payload (middle part)
with [_header, payload, _sig] <- String.split(token, "."),
{:ok, json} <- Base.url_decode64(payload, padding: false) do
Supabase.decode_json(json)
else
:error -> {:error, :invalid_jwt}
_ -> {:error, :invalid_jwt_format}
end
end
end