Current section

Files

Jump to
ash_authentication lib ash_authentication strategies webauthn verifier.ex
Raw

lib/ash_authentication/strategies/webauthn/verifier.ex

# SPDX-FileCopyrightText: 2026 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.WebAuthn.Verifier do
@moduledoc """
DSL verifier for the WebAuthn strategy.
Validates configuration at compile time.
"""
alias Ash.Resource.Info, as: ResourceInfo
alias AshAuthentication.Strategy.WebAuthn
alias Spark.Error.DslError
@doc false
@spec verify(WebAuthn.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, dsl_state) do
with :ok <- validate_wax_dependency(),
:ok <- validate_rp_id(strategy),
:ok <- validate_credential_resource(strategy),
:ok <- validate_credentials_relationship(strategy, dsl_state),
:ok <- validate_credential_resource_shape(strategy),
:ok <- validate_tokens_enabled(dsl_state),
:ok <- validate_verify_action(strategy, dsl_state) do
validate_hashed_password_optional(strategy, dsl_state)
end
end
defp validate_wax_dependency do
if Code.ensure_loaded?(Wax) do
:ok
else
{:error,
DslError.exception(
path: [:authentication, :strategies, :webauthn],
message: """
The WebAuthn strategy requires the optional `:wax_` dependency.
Add it to your dependencies:
{:wax_, "~> 0.7"}
If `ash_authentication` was already compiled without `:wax_`, recompile it:
mix deps.compile ash_authentication --force
"""
)}
end
end
defp validate_rp_id(%{rp_id: rp_id}) when is_binary(rp_id) do
cond do
String.starts_with?(rp_id, "http://") or String.starts_with?(rp_id, "https://") ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :webauthn],
message:
"rp_id must be a domain name without protocol prefix (e.g. \"example.com\", not \"https://example.com\")"
)}
String.contains?(rp_id, "/") ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :webauthn],
message:
"rp_id must be a domain name without paths (e.g. \"example.com\", not \"example.com/auth\")"
)}
true ->
:ok
end
end
defp validate_rp_id(_), do: :ok
defp validate_credential_resource(%{credential_resource: nil}) do
{:error,
DslError.exception(
path: [:authentication, :strategies, :webauthn],
message: "credential_resource is required"
)}
end
defp validate_credential_resource(_), do: :ok
defp validate_credentials_relationship(strategy, dsl_state) do
case ResourceInfo.relationship(dsl_state, strategy.credentials_relationship_name) do
nil ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: """
The user resource is missing the `#{inspect(strategy.credentials_relationship_name)}` relationship.
Add a `has_many` relationship to the credential resource:
relationships do
has_many :#{strategy.credentials_relationship_name}, #{inspect(strategy.credential_resource)}
end
"""
)}
%{type: :has_many, destination: destination}
when destination == strategy.credential_resource ->
:ok
%{type: type} ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"The `#{inspect(strategy.credentials_relationship_name)}` relationship must be a `has_many` to `#{inspect(strategy.credential_resource)}` (found `#{type}`)."
)}
end
end
# The credential resource may be in `no_depend_modules`, so it may not be
# compiled when the user resource is verified. Skip shape checks if we
# can't introspect it yet — they will run when the credential resource
# is itself compiled.
defp validate_credential_resource_shape(strategy) do
if Code.ensure_loaded?(strategy.credential_resource) and
function_exported?(strategy.credential_resource, :spark_dsl_config, 0) do
with :ok <- validate_belongs_to_user(strategy),
:ok <- validate_required_attribute(strategy, strategy.credential_id_field, :binary),
:ok <-
validate_required_attribute(
strategy,
strategy.public_key_field,
WebAuthn.CoseKey
) do
validate_required_attribute(strategy, strategy.sign_count_field, :integer)
end
else
:ok
end
end
defp validate_belongs_to_user(strategy) do
case ResourceInfo.relationship(strategy.credential_resource, strategy.user_relationship_name) do
%{type: :belongs_to} ->
:ok
nil ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: """
The credential resource `#{inspect(strategy.credential_resource)}` is missing the \
`#{inspect(strategy.user_relationship_name)}` relationship.
Add a `belongs_to` relationship pointing to the user resource:
relationships do
belongs_to :#{strategy.user_relationship_name}, MyApp.Accounts.User, allow_nil?: false
end
"""
)}
%{type: type} ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"The `#{inspect(strategy.user_relationship_name)}` relationship on `#{inspect(strategy.credential_resource)}` must be a `belongs_to` (found `#{type}`)."
)}
end
end
defp validate_required_attribute(strategy, name, expected_type) do
case ResourceInfo.attribute(strategy.credential_resource, name) do
nil ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"The credential resource `#{inspect(strategy.credential_resource)}` is missing the `#{inspect(name)}` attribute."
)}
%{type: type} ->
if attribute_type_matches?(type, expected_type) do
:ok
else
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"The `#{inspect(name)}` attribute on `#{inspect(strategy.credential_resource)}` must have type `#{inspect(expected_type)}` (found `#{inspect(type)}`)."
)}
end
end
end
defp attribute_type_matches?(actual, expected) do
Ash.Type.get_type(actual) == Ash.Type.get_type(expected)
end
defp validate_tokens_enabled(dsl_state) do
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state) do
:ok
else
{:error,
DslError.exception(
path: [:authentication, :strategies, :webauthn],
message: """
The WebAuthn strategy requires tokens to be enabled.
Add the following to your authentication block:
authentication do
tokens do
enabled? true
token_resource YourApp.Accounts.Token
signing_secret YourApp.Secrets
end
end
"""
)}
end
end
defp validate_verify_action(%{verify_enabled?: false}, _dsl_state), do: :ok
defp validate_verify_action(strategy, dsl_state) do
case Ash.Resource.Info.action(dsl_state, strategy.verify_action_name) do
nil ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: """
The WebAuthn strategy expects a `#{inspect(strategy.verify_action_name)}` read \
action on `#{inspect(strategy.resource)}` (auto-generated by the strategy's \
transformer when `verify_enabled? true`). It looks like the action is missing.
"""
)}
_action ->
:ok
end
end
# WebAuthn registration creates a user without a password, so when the
# password strategy also exists on the same resource, `hashed_password` must
# be nil-able. Surface the conflict at compile time instead of at the first
# registration attempt.
defp validate_hashed_password_optional(strategy, dsl_state) do
with true <- strategy.registration_enabled?,
true <- AshAuthentication.Info.strategy_enabled?(dsl_state, :password),
%{allow_nil?: false} <- ResourceInfo.attribute(dsl_state, :hashed_password) do
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: """
The `:hashed_password` attribute is `allow_nil? false`, but the WebAuthn
strategy registers users without a password. Both strategies can coexist
on the same resource only if `:hashed_password` is nil-able.
Update the attribute on `#{inspect(strategy.resource)}`:
attribute :hashed_password, :string do
sensitive? true
end
(i.e. drop the `allow_nil? false` line — the default is `true`.)
"""
)}
else
_ -> :ok
end
end
end