diff --git a/.formatter.exs b/.formatter.exs index 474d0db..34a4427 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -8,6 +8,7 @@ spark_locals_without_parens = [ auth_method: 1, authorization_params: 1, authorize_url: 1, + client_authentication_method: 1, client_id: 1, client_secret: 1, confirm_action_name: 1, @@ -30,6 +31,9 @@ spark_locals_without_parens = [ github: 2, hash_provider: 1, hashed_password_field: 1, + icon: 1, + id_token_signed_response_alg: 1, + id_token_ttl_seconds: 1, identity_field: 1, identity_relationship_name: 1, identity_relationship_user_id_attribute: 1, @@ -40,9 +44,15 @@ spark_locals_without_parens = [ magic_link: 1, magic_link: 2, monitor_fields: 1, + nonce: 1, oauth2: 0, oauth2: 1, oauth2: 2, + oidc: 0, + oidc: 1, + oidc: 2, + openid_configuration: 1, + openid_configuration_uri: 1, password: 0, password: 1, password: 2, @@ -82,6 +92,7 @@ spark_locals_without_parens = [ token_param_name: 1, token_resource: 1, token_url: 1, + trusted_audiences: 1, uid_attribute_name: 1, upsert_action_name: 1, user_id_attribute_name: 1, diff --git a/.github/workflows/elixir_lib.yml b/.github/workflows/elixir_lib.yml index be65f93..a385337 100644 --- a/.github/workflows/elixir_lib.yml +++ b/.github/workflows/elixir_lib.yml @@ -2,8 +2,6 @@ name: Elixir Library on: push: - pull_request: - branches: [main] jobs: deps: diff --git a/.vscode/settings.json b/.vscode/settings.json index 9bf6414..f804e03 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,10 @@ "defimpl", "defstruct", "ilike", + "Joken", "Marties", - "moduledocs" + "moduledocs", + "oidc", + "unguessable" ] } diff --git a/config/dev.exs b/config/dev.exs index 38d80b0..0e08475 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -51,6 +51,14 @@ config :ash_authentication, client_id: System.get_env("GITHUB_CLIENT_ID"), client_secret: System.get_env("GITHUB_CLIENT_SECRET"), redirect_uri: "http://localhost:4000/auth" + ], + oidc: [ + authorize_url: "#{System.get_env("OAUTH2_SITE")}/authorize", + client_id: System.get_env("OAUTH2_CLIENT_ID"), + client_secret: System.get_env("OAUTH2_CLIENT_SECRET"), + redirect_uri: "http://localhost:4000/auth", + site: System.get_env("OAUTH2_SITE"), + token_url: "#{System.get_env("OAUTH2_SITE")}/oauth/token" ] ], tokens: [ diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex index fc48c55..c1e98ab 100644 --- a/dev/dev_server/test_page.ex +++ b/dev/dev_server/test_page.ex @@ -141,6 +141,10 @@ defmodule DevServer.TestPage do ) end + defp render_strategy(strategy, phase, _) + when strategy.provider == :password and phase == :sign_in_with_token, + do: "" + defp render_strategy(strategy, phase, options) when strategy.provider == :confirmation and phase == :confirm do EEx.eval_string( diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index bae2451..bd2bbbf 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -103,25 +103,23 @@ defmodule AshAuthentication do Resource } - alias AshAuthentication.{ - AddOn.Confirmation, - Info, - Strategy.Auth0, - Strategy.Github, - Strategy.MagicLink, - Strategy.OAuth2, - Strategy.Password - } + alias AshAuthentication.Info alias Spark.Dsl.Extension + @built_in_strategies [ + AshAuthentication.AddOn.Confirmation, + AshAuthentication.Strategy.Auth0, + AshAuthentication.Strategy.Github, + AshAuthentication.Strategy.OAuth2, + AshAuthentication.Strategy.Oidc, + AshAuthentication.Strategy.Password, + AshAuthentication.Strategy.MagicLink + ] + use Spark.Dsl.Extension, sections: dsl(), - dsl_patches: - Enum.flat_map( - [Confirmation, Auth0, Github, OAuth2, Password, MagicLink], - & &1.dsl_patches() - ), + dsl_patches: Enum.flat_map(@built_in_strategies, & &1.dsl_patches()), transformers: [ AshAuthentication.Transformer, AshAuthentication.Transformer.SetSelectForSenders, @@ -236,4 +234,8 @@ defmodule AshAuthentication do end end end + + @doc false + @spec __built_in_strategies__ :: [module] + def __built_in_strategies__, do: @built_in_strategies end diff --git a/lib/ash_authentication/add_ons/confirmation.ex b/lib/ash_authentication/add_ons/confirmation.ex index 0c63f99..322660a 100644 --- a/lib/ash_authentication/add_ons/confirmation.ex +++ b/lib/ash_authentication/add_ons/confirmation.ex @@ -92,17 +92,18 @@ defmodule AshAuthentication.AddOn.Confirmation do #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} """ - defstruct token_lifetime: nil, - monitor_fields: [], - confirmed_at_field: :confirmed_at, + defstruct confirm_action_name: :confirm, confirm_on_create?: true, confirm_on_update?: true, + confirmed_at_field: :confirmed_at, inhibit_updates?: false, - sender: nil, - confirm_action_name: :confirm, - resource: nil, + monitor_fields: [], + name: :confirm, provider: :confirmation, - name: :confirm + resource: nil, + sender: nil, + strategy_module: __MODULE__, + token_lifetime: nil alias Ash.{Changeset, Resource} alias AshAuthentication.{AddOn.Confirmation, Jwt, Strategy.Custom} @@ -110,17 +111,18 @@ defmodule AshAuthentication.AddOn.Confirmation do use Custom, style: :add_on, entity: Dsl.dsl() @type t :: %Confirmation{ - token_lifetime: hours :: pos_integer, - monitor_fields: [atom], - confirmed_at_field: atom, + confirm_action_name: atom, confirm_on_create?: boolean, confirm_on_update?: boolean, + confirmed_at_field: atom, inhibit_updates?: boolean, - sender: nil | {module, keyword}, - confirm_action_name: atom, - resource: module, + monitor_fields: [atom], + name: :confirm, provider: :confirmation, - name: :confirm + resource: module, + sender: nil | {module, keyword}, + strategy_module: module, + token_lifetime: hours :: pos_integer } defdelegate transform(strategy, dsl_state), to: Transformer diff --git a/lib/ash_authentication/strategies/auth0/dsl.ex b/lib/ash_authentication/strategies/auth0/dsl.ex index bc92425..bf3a67f 100644 --- a/lib/ash_authentication/strategies/auth0/dsl.ex +++ b/lib/ash_authentication/strategies/auth0/dsl.ex @@ -13,7 +13,7 @@ defmodule AshAuthentication.Strategy.Auth0.Dsl do describe: """ Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/). - This strategy is built using `:oauth2` strategy, and thus provides all the same + This strategy is built using the `:oauth2` strategy, and thus provides all the same configuration options should you need them. For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md) diff --git a/lib/ash_authentication/strategies/custom.ex b/lib/ash_authentication/strategies/custom.ex index eca75b6..bdf4c41 100644 --- a/lib/ash_authentication/strategies/custom.ex +++ b/lib/ash_authentication/strategies/custom.ex @@ -13,10 +13,21 @@ defmodule AshAuthentication.Strategy.Custom do See `Spark.Dsl.Entity` for more information. """ - # credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct - @type entity :: %Dsl.Entity{} + @type entity :: Spark.Dsl.Entity.t() - @type strategy :: struct + @typedoc """ + This is the DSL target for your entity and the struct for which you will + implement the `AshAuthentication.Strategy` protocol. + + The only required field is `strategy_module` which is used to keep track of + which custom strategy created which strategy. + """ + @type strategy :: %{ + required(:__struct__) => module, + required(:strategy_module) => module, + required(:resource) => module, + optional(atom) => any + } @doc """ If your strategy needs to modify either the entity or the parent resource then @@ -80,7 +91,11 @@ defmodule AshAuthentication.Strategy.Custom do |> Keyword.get(:entity) |> case do %Dsl.Entity{} = entity -> - entity + %{ + entity + | auto_set_fields: + Keyword.merge([strategy_module: __MODULE__], entity.auto_set_fields || []) + } _ -> raise CompileError, diff --git a/lib/ash_authentication/strategies/custom/before_compileex b/lib/ash_authentication/strategies/custom/before_compileex deleted file mode 100644 index 8ce2c8f..0000000 --- a/lib/ash_authentication/strategies/custom/before_compileex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule AshAuthentication.Strategy.Custom.BeforeCompile do - @moduledoc false - alias Spark.Dsl - - defmacro __before_compile__(env) do - quote generated: true do - use Dsl.Extension, - dsl_patches: [ - %Dsl.Patch.AddEntity{ - section_path: @patch_path, - entity: dsl() - } - ] - end - end -end diff --git a/lib/ash_authentication/strategies/custom/transformer.ex b/lib/ash_authentication/strategies/custom/transformer.ex index 35a064f..143c106 100644 --- a/lib/ash_authentication/strategies/custom/transformer.ex +++ b/lib/ash_authentication/strategies/custom/transformer.ex @@ -32,62 +32,62 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do | {:warn, map(), String.t() | [String.t()]} | :halt def transform(dsl_state) do - strategy_to_target = - :code.all_available() - |> Stream.map(&elem(&1, 0)) - |> Stream.map(&to_string/1) - |> Stream.filter(&String.starts_with?(&1, "Elixir.AshAuthentication")) - |> Stream.map(&Module.concat([&1])) - |> Stream.concat(Transformer.get_persisted(dsl_state, :extensions, [])) - |> Stream.filter(&Spark.implements_behaviour?(&1, Strategy.Custom)) - |> Stream.flat_map(fn strategy -> - strategy.dsl_patches() - |> Stream.map(&{&1.entity.target, strategy}) - end) - |> Map.new() - - dsl_state = - Transformer.persist(dsl_state, :ash_authentication_strategy_to_target, strategy_to_target) - - with {:ok, dsl_state} <- do_strategy_transforms(dsl_state, strategy_to_target) do - do_add_on_transforms(dsl_state, strategy_to_target) + with {:ok, dsl_state} <- do_strategy_transforms(dsl_state) do + do_add_on_transforms(dsl_state) end end - defp do_strategy_transforms(dsl_state, strategy_to_target) do + defp do_strategy_transforms(dsl_state) do dsl_state |> Info.authentication_strategies() |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> - strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__) - - case do_transform(strategy_module, strategy, dsl_state, :strategy) do + case do_transform(strategy, dsl_state, :strategy) do {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} {:error, reason} -> {:halt, {:error, reason}} end end) end - defp do_add_on_transforms(dsl_state, strategy_to_target) do + defp do_add_on_transforms(dsl_state) do dsl_state |> Info.authentication_add_ons() |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> - strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__) - - case do_transform(strategy_module, strategy, dsl_state, :add_on) do + case do_transform(strategy, dsl_state, :add_on) do {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} {:error, reason} -> {:halt, {:error, reason}} end end) end - defp do_transform(strategy_module, strategy, dsl_state, :strategy) - when is_map_key(strategy, :resource) do + defp do_transform(strategy, _, _) when not is_map_key(strategy, :strategy_module) do + name = Strategy.name(strategy) + + {:error, + DslError.exception( + path: [:authentication, name], + message: + "The struct defined by `#{inspect(strategy.__struct__)}` must contain a `strategy_module` field." + )} + end + + defp do_transform(strategy, _, _) when not is_map_key(strategy, :resource) do + name = Strategy.name(strategy) + + {:error, + DslError.exception( + path: [:authentication, name], + message: + "The struct defined by `#{inspect(strategy.__struct__)}` must contain a `resource` field." + )} + end + + defp do_transform(strategy, dsl_state, :strategy) do strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)} dsl_state = put_strategy(dsl_state, strategy) entity_module = strategy.__struct__ strategy - |> strategy_module.transform(dsl_state) + |> strategy.strategy_module.transform(dsl_state) |> case do {:ok, strategy} when is_struct(strategy, entity_module) -> {:ok, put_strategy(dsl_state, strategy)} @@ -100,14 +100,13 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do end end - defp do_transform(strategy_module, strategy, dsl_state, :add_on) - when is_map_key(strategy, :resource) do + defp do_transform(strategy, dsl_state, :add_on) do strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)} dsl_state = put_add_on(dsl_state, strategy) entity_module = strategy.__struct__ strategy - |> strategy_module.transform(dsl_state) + |> strategy.strategy_module.transform(dsl_state) |> case do {:ok, strategy} when is_struct(strategy, entity_module) -> {:ok, put_add_on(dsl_state, strategy)} @@ -119,15 +118,4 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do {:error, reason} end end - - defp do_transform(_strategy_module, strategy, _, _) do - name = Strategy.name(strategy) - - {:error, - DslError.exception( - path: [:authentication, name], - message: - "The struct defined by `#{inspect(strategy.__struct__)}` must contain a `resource` field." - )} - end end diff --git a/lib/ash_authentication/strategies/custom/verifier.ex b/lib/ash_authentication/strategies/custom/verifier.ex index bbce958..178bc6d 100644 --- a/lib/ash_authentication/strategies/custom/verifier.ex +++ b/lib/ash_authentication/strategies/custom/verifier.ex @@ -8,7 +8,6 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do use Spark.Dsl.Verifier alias AshAuthentication.Info - alias Spark.Dsl.Transformer @doc false @impl true @@ -17,22 +16,17 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do | {:error, term} | {:warn, String.t() | list(String.t())} def verify(dsl_state) do - strategy_to_target = - dsl_state - |> Transformer.get_persisted(:ash_authentication_strategy_to_target, %{}) - dsl_state |> Info.authentication_strategies() |> Stream.concat(Info.authentication_add_ons(dsl_state)) - |> Enum.reduce_while(:ok, fn strategy, :ok -> - strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__) - - strategy - |> strategy_module.verify(dsl_state) - |> case do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end + |> Enum.reduce_while(:ok, fn + strategy, :ok -> + strategy + |> strategy.strategy_module.verify(dsl_state) + |> case do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end end) end end diff --git a/lib/ash_authentication/strategies/github/dsl.ex b/lib/ash_authentication/strategies/github/dsl.ex index bbab990..82c8d74 100644 --- a/lib/ash_authentication/strategies/github/dsl.ex +++ b/lib/ash_authentication/strategies/github/dsl.ex @@ -13,7 +13,7 @@ defmodule AshAuthentication.Strategy.Github.Dsl do describe: """ Provides a pre-configured authentication strategy for [GitHub](https://github.com/). - This strategy is built using `:oauth2` strategy, and thus provides all the same + This strategy is built using the `:oauth2` strategy, and thus provides all the same configuration options should you need them. For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md) diff --git a/lib/ash_authentication/strategies/magic_link.ex b/lib/ash_authentication/strategies/magic_link.ex index 621bd2a..f00179d 100644 --- a/lib/ash_authentication/strategies/magic_link.ex +++ b/lib/ash_authentication/strategies/magic_link.ex @@ -107,6 +107,7 @@ defmodule AshAuthentication.Strategy.MagicLink do sender: nil, sign_in_action_name: nil, single_use_token?: true, + strategy_module: __MODULE__, token_lifetime: 10, token_param_name: :token @@ -123,6 +124,7 @@ defmodule AshAuthentication.Strategy.MagicLink do sender: {module, keyword}, single_use_token?: boolean, sign_in_action_name: atom, + strategy_module: module, token_lifetime: pos_integer(), token_param_name: atom } diff --git a/lib/ash_authentication/strategies/oauth2.ex b/lib/ash_authentication/strategies/oauth2.ex index e0f49fb..6de9c37 100644 --- a/lib/ash_authentication/strategies/oauth2.ex +++ b/lib/ash_authentication/strategies/oauth2.ex @@ -219,27 +219,39 @@ defmodule AshAuthentication.Strategy.OAuth2 do #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} """ - defstruct client_id: nil, - site: nil, - auth_method: :client_secret_post, - client_secret: nil, - authorize_url: nil, - token_url: nil, - user_url: nil, - private_key: nil, - redirect_uri: nil, - authorization_params: [], - registration_enabled?: true, - register_action_name: nil, - sign_in_action_name: nil, - identity_resource: false, - identity_relationship_name: :identities, - identity_relationship_user_id_attribute: :user_id, - provider: :oauth2, - name: nil, - resource: nil, - icon: nil, - assent_strategy: Assent.Strategy.OAuth2 + @struct_fields [ + assent_strategy: Assent.Strategy.OAuth2, + auth_method: :client_secret_post, + authorization_params: [], + authorize_url: nil, + client_authentication_method: nil, + client_id: nil, + client_secret: nil, + icon: nil, + id_token_signed_response_alg: nil, + id_token_ttl_seconds: nil, + identity_relationship_name: :identities, + identity_relationship_user_id_attribute: :user_id, + identity_resource: false, + name: nil, + nonce: false, + openid_configuration_uri: nil, + openid_configuration: nil, + private_key: nil, + provider: :oauth2, + redirect_uri: nil, + register_action_name: nil, + registration_enabled?: true, + resource: nil, + sign_in_action_name: nil, + site: nil, + strategy_module: __MODULE__, + token_url: nil, + trusted_audiences: nil, + user_url: nil + ] + + defstruct @struct_fields alias AshAuthentication.Strategy.{Custom, OAuth2} @@ -248,32 +260,40 @@ defmodule AshAuthentication.Strategy.OAuth2 do @type secret :: nil | String.t() | {module, keyword} @type t :: %OAuth2{ - client_id: secret, - site: secret, + assent_strategy: module, auth_method: nil | :client_secret_basic | :client_secret_post | :client_secret_jwt | :private_key_jwt, - client_secret: secret, - authorize_url: secret, - token_url: secret, - user_url: secret, - private_key: secret, - redirect_uri: secret, authorization_params: keyword, - registration_enabled?: boolean, - register_action_name: atom, - sign_in_action_name: atom, - identity_resource: module | false, + authorize_url: secret, + client_authentication_method: nil | atom, + client_id: secret, + client_secret: secret, + icon: nil | atom, + id_token_signed_response_alg: nil | binary, + id_token_ttl_seconds: nil | pos_integer(), identity_relationship_name: atom, identity_relationship_user_id_attribute: atom, - provider: atom, + identity_resource: module | false, name: atom, + nonce: boolean | secret, + openid_configuration_uri: nil | binary, + openid_configuration: nil | map, + private_key: secret, + provider: atom, + redirect_uri: secret, + register_action_name: atom, + registration_enabled?: boolean, resource: module, - icon: nil | atom, - assent_strategy: module + sign_in_action_name: atom, + site: secret, + strategy_module: module, + token_url: secret, + trusted_audiences: nil | [binary], + user_url: secret } defdelegate dsl, to: Dsl diff --git a/lib/ash_authentication/strategies/oauth2/dsl.ex b/lib/ash_authentication/strategies/oauth2/dsl.ex index 86f583e..1ddb503 100644 --- a/lib/ash_authentication/strategies/oauth2/dsl.ex +++ b/lib/ash_authentication/strategies/oauth2/dsl.ex @@ -284,6 +284,16 @@ defmodule AshAuthentication.Strategy.OAuth2.Dsl do `user_id_attribute_name` option of the provider identity. """, default: :user_id + ], + icon: [ + type: :atom, + doc: """ + The name of an icon to use in any potential UI. + + This is a *hint* for UI generators to use, and not in any way canonical. + """, + required: false, + default: :oauth2 ] ], auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2] diff --git a/lib/ash_authentication/strategies/oauth2/plug.ex b/lib/ash_authentication/strategies/oauth2/plug.ex index 1589c67..5d4039e 100644 --- a/lib/ash_authentication/strategies/oauth2/plug.ex +++ b/lib/ash_authentication/strategies/oauth2/plug.ex @@ -11,6 +11,15 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] import Plug.Conn + @raw_config_attrs [ + :auth_method, + :authorization_params, + :client_authentication_method, + :id_token_signed_response_alg, + :id_token_ttl_seconds, + :openid_configuration_uri + ] + @doc """ Perform the request phase of OAuth2. @@ -20,6 +29,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do @spec request(Conn.t(), OAuth2.t()) :: Conn.t() def request(conn, strategy) do with {:ok, config} <- config_for(strategy), + {:ok, config} <- maybe_add_nonce(config, strategy), {:ok, session_key} <- session_key(strategy), {:ok, %{session_params: session_params, url: url}} <- strategy.assent_strategy.authorize_url(config) do @@ -68,27 +78,25 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do end defp config_for(strategy) do - with {:ok, client_id} <- fetch_secret(strategy, :client_id), - {:ok, site} <- fetch_secret(strategy, :site), + config = + strategy + |> Map.take(@raw_config_attrs) + |> Map.put(:http_adapter, Mint) + + with {:ok, config} <- add_secret_value(config, strategy, :authorize_url), + {:ok, config} <- add_secret_value(config, strategy, :client_id), + {:ok, config} <- add_secret_value(config, strategy, :client_secret), + {:ok, config} <- add_secret_value(config, strategy, :site), + {:ok, config} <- add_secret_value(config, strategy, :token_url), + {:ok, config} <- add_secret_value(config, strategy, :user_url, !!strategy.authorize_url), {:ok, redirect_uri} <- build_redirect_uri(strategy), - {:ok, authorize_url} <- fetch_secret(strategy, :authorize_url), - {:ok, token_url} <- fetch_secret(strategy, :token_url), - {:ok, user_url} <- fetch_secret(strategy, :user_url) do + {:ok, jwt_algorithm} <- + Info.authentication_tokens_signing_algorithm(strategy.resource) do config = - [ - auth_method: strategy.auth_method, - client_id: client_id, - client_secret: get_secret(strategy, :client_secret), - private_key: get_secret(strategy, :private_key), - jwt_algorithm: Info.authentication_tokens_signing_algorithm(strategy.resource), - authorization_params: strategy.authorization_params, - redirect_uri: redirect_uri, - site: site, - authorize_url: authorize_url, - token_url: token_url, - user_url: user_url, - http_adapter: Mint - ] + config + |> Map.put(:jwt_algorithm, jwt_algorithm) + |> Map.put(:redirect_uri, redirect_uri) + |> Map.update(:client_authentication_method, nil, &to_string/1) |> Enum.reject(&is_nil(elem(&1, 1))) {:ok, config} @@ -116,6 +124,34 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do end end + # With OpenID Connect we can pass a "nonce" value into the assent strategy + # which is an additional way to ensure that the callback matches the request. + defp maybe_add_nonce(config, strategy) do + case fetch_secret(strategy, :nonce) do + {:ok, value} when is_binary(value) and byte_size(value) > 0 -> + {:ok, Keyword.put(config, :nonce, value)} + + {:ok, false} -> + {:ok, config} + + {:error, reason} -> + {:error, reason} + end + end + + defp add_secret_value(config, strategy, secret_name, allow_nil? \\ false) do + case fetch_secret(strategy, secret_name) do + {:ok, nil} when allow_nil? -> + {:ok, config} + + {:ok, value} when is_binary(value) and byte_size(value) > 0 -> + {:ok, Map.put(config, secret_name, value)} + + {:error, reason} -> + {:error, reason} + end + end + defp fetch_secret(strategy, secret_name) do path = [:authentication, :strategies, strategy.name, secret_name] @@ -124,15 +160,11 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do secret_module.secret_for(path, strategy.resource, secret_opts) do {:ok, secret} else - {:ok, secret} when is_binary(secret) -> {:ok, secret} - _ -> {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)} - end - end + {:ok, secret} -> + {:ok, secret} - defp get_secret(strategy, secret_name) do - case fetch_secret(strategy, secret_name) do - {:ok, secret} -> secret - _ -> nil + _ -> + {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)} end end diff --git a/lib/ash_authentication/strategies/oauth2/verifier.ex b/lib/ash_authentication/strategies/oauth2/verifier.ex index 237a4d4..772efab 100644 --- a/lib/ash_authentication/strategies/oauth2/verifier.ex +++ b/lib/ash_authentication/strategies/oauth2/verifier.ex @@ -3,8 +3,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do DSL verifier for oauth2 strategies. """ - alias AshAuthentication.{Secret, Strategy.OAuth2} - alias Spark.Error.DslError + alias AshAuthentication.Strategy.OAuth2 import AshAuthentication.Validations @doc false @@ -17,28 +16,11 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do :ok <- validate_secret(strategy, :site), :ok <- validate_secret(strategy, :token_url), :ok <- validate_secret(strategy, :user_url) do - validate_secret(strategy, :private_key, strategy.auth_method != :private_key_jwt) - end - end - - defp validate_secret(strategy, option, allow_nil \\ false) do - case Map.fetch(strategy, option) do - {:ok, value} when is_binary(value) -> + if strategy.auth_method == :private_key_jwt do + validate_secret(strategy, :private_key) + else :ok - - {:ok, nil} when allow_nil -> - :ok - - {:ok, {module, _}} when is_atom(module) -> - validate_behaviour(module, Secret) - - _ -> - {:error, - DslError.exception( - path: [:authentication, :strategies, :oauth2], - message: - "Expected `#{inspect(option)}` to be either a string or a module which implements the `AshAuthentication.Secret` behaviour." - )} + end end end end diff --git a/lib/ash_authentication/strategies/oidc.ex b/lib/ash_authentication/strategies/oidc.ex new file mode 100644 index 0000000..a1f3c81 --- /dev/null +++ b/lib/ash_authentication/strategies/oidc.ex @@ -0,0 +1,60 @@ +defmodule AshAuthentication.Strategy.Oidc do + alias __MODULE__.Dsl + + @moduledoc """ + Strategy for authentication using an [OpenID + Connect](https://openid.net/connect/) compatible server as the source of + truth. + + This strategy builds on-top of `AshAuthentication.Strategy.OAuth2` and + [`assent`](https://hex.pm/packages/assent). + + In order to use OIDC you need to provide the following minimum configuration: + + - `client_id` - The client id, required + - `site` - The OIDC issuer, required + - `openid_configuration_uri` - The URI for OpenID Provider, optional, defaults + to `/.well-known/openid-configuration` + - `client_authentication_method` - The Client Authentication method to use, + optional, defaults to `client_secret_basic` + - `client_secret` - The client secret, required if + `:client_authentication_method` is `:client_secret_basic`, + `:client_secret_post`, or `:client_secret_jwt` + - `openid_configuration` - The OpenID configuration, optional, the + configuration will be fetched from `:openid_configuration_uri` if this is + not defined + - `id_token_signed_response_alg` - The `id_token_signed_response_alg` + parameter sent by the Client during Registration, defaults to `RS256` + - `id_token_ttl_seconds` - The number of seconds from `iat` that an ID Token + will be considered valid, optional, defaults to nil + - `nonce` - The nonce to use for authorization request, optional, MUST be + session based and unguessable. + + + ## Nonce + `nonce` can be set in the provider config. The `nonce` will be returned in the + `session_params` along with `state`. You can use this to store the value in + the current session e.g. a httpOnly session cookie. + + A random value generator can look like this: + + ```elixir + 16 + |> :crypto.strong_rand_bytes() + |> Base.encode64(padding: false) + ``` + + AshAuthentication will dynamically generate one for the session if `nonce` is + set to `true`. + + ## DSL Documentation + + #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} + """ + + alias AshAuthentication.Strategy.{Custom, Oidc} + use Custom, entity: Dsl.dsl() + + defdelegate transform(strategy, dsl_state), to: Oidc.Transformer + defdelegate verify(strategy, dsl_state), to: Oidc.Verifier +end diff --git a/lib/ash_authentication/strategies/oidc/dsl.ex b/lib/ash_authentication/strategies/oidc/dsl.ex new file mode 100644 index 0000000..bc65428 --- /dev/null +++ b/lib/ash_authentication/strategies/oidc/dsl.ex @@ -0,0 +1,104 @@ +defmodule AshAuthentication.Strategy.Oidc.Dsl do + @moduledoc false + + alias AshAuthentication.Strategy.{Custom, OAuth2} + + @doc false + @spec dsl :: Custom.entity() + def dsl do + OAuth2.dsl() + |> Map.merge(%{ + name: :oidc, + args: [{:optional, :name, :oidc}], + describe: """ + Provides an OpenID Connect authentication strategy. + + This strategy is built using the `:oauth2` strategy, and thus provides + all the same configuration options should you need them. + + #### Schema: + """, + auto_set_fields: [assent_strategy: Assent.Strategy.OIDC, icon: :oidc], + schema: patch_schema() + }) + end + + defp patch_schema do + OAuth2.dsl() + |> Map.get(:schema, []) + |> Keyword.delete(:user_url) + |> Keyword.merge( + openid_configuration_uri: [ + type: :string, + default: "/.well-known/openid-configuration", + doc: "The URI for the OpenID provider", + required: false + ], + client_authentication_method: [ + type: + {:in, [:client_secret_basic, :client_secret_post, :client_secret_jwt, :private_key_jwt]}, + default: :client_secret_basic, + doc: "The client authentication method to use.", + required: false + ], + openid_configuration: [ + type: :map, + doc: """ + The OpenID configuration. + + If not set, the configuration will be retrieved from `openid_configuration_uri`. + """, + required: false, + default: %{} + ], + id_token_signed_response_alg: [ + type: {:in, Joken.Signer.algorithms()}, + doc: """ + The `id_token_signed_response_alg` parameter sent by the Client during Registration. + """, + required: false, + default: "RS256" + ], + id_token_ttl_seconds: [ + type: {:or, [nil, :pos_integer]}, + doc: """ + The number of seconds from `iat` that an ID Token will be considered valid. + """, + required: false, + default: nil + ], + nonce: [ + type: {:or, [:boolean, AshAuthentication.Dsl.secret_type()]}, + doc: """ + A function for generating the session nonce. + + When set to `true` the nonce will be automatically generated using + `AshAuthentication.Strategy.Oidc.NonceGenerator`. Set to `false` + to explicitly disable. + + #{AshAuthentication.Dsl.secret_doc()} + + Example: + + ```elixir + nonce fn _, _ -> + 16 + |> :crypto.strong_rand_bytes() + |> Base.encode64(padding: false) + end + ``` + """, + default: true, + required: false + ], + trusted_audiences: [ + type: {:or, [nil, {:list, :string}]}, + doc: """ + A list of audiences which are trusted. + """, + default: nil, + required: false + ] + ) + end +end diff --git a/lib/ash_authentication/strategies/oidc/nonce_generator.ex b/lib/ash_authentication/strategies/oidc/nonce_generator.ex new file mode 100644 index 0000000..a163025 --- /dev/null +++ b/lib/ash_authentication/strategies/oidc/nonce_generator.ex @@ -0,0 +1,29 @@ +defmodule AshAuthentication.Strategy.Oidc.NonceGenerator do + @moduledoc """ + An implmentation of `AshAuthentication.Secret` that generates nonces for + OpenID Connect strategies. + + Defaults to `16` bytes of random data. You can change this by setting the + `byte_size` option in your DSL: + + ```elixir + oidc do + nonce {AshAuthentication.NonceGenerator, byte_size: 32} + # ... + end + ``` + """ + + use AshAuthentication.Secret + + @doc false + @impl true + @spec secret_for(secret_name :: [atom], Ash.Resource.t(), keyword) :: {:ok, String.t()} | :error + def secret_for(_secret_name, _resource, opts) do + opts + |> Keyword.get(:byte_size, 16) + |> :crypto.strong_rand_bytes() + |> Base.encode64(padding: false) + |> then(&{:ok, &1}) + end +end diff --git a/lib/ash_authentication/strategies/oidc/transformer.ex b/lib/ash_authentication/strategies/oidc/transformer.ex new file mode 100644 index 0000000..2630892 --- /dev/null +++ b/lib/ash_authentication/strategies/oidc/transformer.ex @@ -0,0 +1,20 @@ +defmodule AshAuthentication.Strategy.Oidc.Transformer do + @moduledoc """ + DSL transformer for oidc strategies. + + Adds a nonce generator to the strategy if `nonce` is set to `true`. + Delegates to the default OAuth2 transformer. + """ + + alias AshAuthentication.Strategy.{OAuth2, Oidc.NonceGenerator} + + @doc false + @spec transform(OAuth2.t(), map) :: {:ok, OAuth2.t() | map} | {:error, Exception.t()} + def transform(strategy, dsl_state) when strategy.nonce == true do + strategy + |> Map.put(:nonce, {NonceGenerator, []}) + |> OAuth2.transform(dsl_state) + end + + def transform(strategy, dsl_state), do: OAuth2.transform(strategy, dsl_state) +end diff --git a/lib/ash_authentication/strategies/oidc/verifier.ex b/lib/ash_authentication/strategies/oidc/verifier.ex new file mode 100644 index 0000000..c02c742 --- /dev/null +++ b/lib/ash_authentication/strategies/oidc/verifier.ex @@ -0,0 +1,27 @@ +defmodule AshAuthentication.Strategy.Oidc.Verifier do + @moduledoc """ + DSL verifier for OpenID Connect strategy. + """ + + alias AshAuthentication.Strategy.OAuth2 + import AshAuthentication.Validations + + @doc false + @spec verify(OAuth2.t(), map) :: :ok | {:error, Exception.t()} + def verify(strategy, _dsl_state) do + with :ok <- validate_secret(strategy, :authorize_url), + :ok <- validate_secret(strategy, :client_id), + :ok <- validate_secret(strategy, :client_secret), + :ok <- validate_secret(strategy, :redirect_uri), + :ok <- validate_secret(strategy, :site), + :ok <- validate_secret(strategy, :token_url), + :ok <- validate_secret(strategy, :user_url, [nil]), + :ok <- validate_secret(strategy, :nonce, [true, false]) do + if strategy.auth_method == :private_key_jwt do + validate_secret(strategy, :private_key) + else + :ok + end + end + end +end diff --git a/lib/ash_authentication/strategies/password.ex b/lib/ash_authentication/strategies/password.ex index 8212381..4544499 100644 --- a/lib/ash_authentication/strategies/password.ex +++ b/lib/ash_authentication/strategies/password.ex @@ -112,7 +112,8 @@ defmodule AshAuthentication.Strategy.Password do sign_in_enabled?: true, sign_in_token_lifetime: 60, sign_in_tokens_enabled?: false, - sign_in_with_token_action_name: nil + sign_in_with_token_action_name: nil, + strategy_module: nil alias Ash.Resource @@ -145,7 +146,8 @@ defmodule AshAuthentication.Strategy.Password do sign_in_enabled?: boolean, sign_in_token_lifetime: pos_integer, sign_in_tokens_enabled?: boolean, - sign_in_with_token_action_name: atom + sign_in_with_token_action_name: atom, + strategy_module: __MODULE__ } defdelegate dsl(), to: Dsl diff --git a/lib/ash_authentication/validations.ex b/lib/ash_authentication/validations.ex index 8f70e03..35c2760 100644 --- a/lib/ash_authentication/validations.ex +++ b/lib/ash_authentication/validations.ex @@ -165,4 +165,37 @@ defmodule AshAuthentication.Validations do {:error, reason} -> {:error, reason} end end + + @doc """ + Validate that a "secret" field is configured correctly. + """ + def validate_secret(strategy, option, allowed_extras \\ []) do + value = Map.get(strategy, option) + + cond do + is_binary(value) -> + :ok + + value in allowed_extras -> + :ok + + is_tuple(value) and tuple_size(value) == 2 -> + validate_behaviour(elem(value, 0), AshAuthentication.Secret) + + true -> + message = + case allowed_extras do + [] -> + "Expected `#{inspect(option)}` to be a string or a module which implements the `AshAuthentication.Secret` behaviour." + + _ -> + options = Enum.map_join(allowed_extras, ", ", &"`#{inspect(&1)}`") + + "Expected `#{inspect(option)}` to be #{options}, a string or a module which implements the `AshAuthentication.Secret` behaviour." + end + + {:error, + DslError.exception(path: [:authentication, :strategies, strategy.name], message: message)} + end + end end diff --git a/mix.exs b/mix.exs index 5c2aa9a..9347f81 100644 --- a/mix.exs +++ b/mix.exs @@ -146,7 +146,7 @@ defmodule AshAuthentication.MixProject do defp deps do [ {:ash, ash_version("~> 2.5 and >= 2.5.11")}, - {:spark, "~> 1.0"}, + {:spark, "~> 1.0 and >= 1.0.9"}, {:jason, "~> 1.4"}, {:joken, "~> 2.5"}, {:plug, "~> 1.13"}, diff --git a/test/support/example/only_marties_at_the_party.ex b/test/support/example/only_marties_at_the_party.ex index 6841421..f0a1da7 100644 --- a/test/support/example/only_marties_at_the_party.ex +++ b/test/support/example/only_marties_at_the_party.ex @@ -3,7 +3,11 @@ defmodule Example.OnlyMartiesAtTheParty do A really dumb custom strategy that lets anyone named Marty sign in. """ - defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil + defstruct name: :marty, + case_sensitive?: false, + name_field: nil, + resource: nil, + strategy_module: __MODULE__ @entity %Spark.Dsl.Entity{ name: :only_marty, diff --git a/test/support/example/user.ex b/test/support/example/user.ex index 0894087..9907859 100644 --- a/test/support/example/user.ex +++ b/test/support/example/user.ex @@ -73,6 +73,17 @@ defmodule Example.User do change AshAuthentication.Strategy.OAuth2.IdentityChange end + create :register_with_oidc do + argument :user_info, :map, allow_nil?: false + argument :oauth_tokens, :map, allow_nil?: false + upsert? true + upsert_identity :username + + change AshAuthentication.GenerateTokenChange + change Example.GenericOAuth2Change + change AshAuthentication.Strategy.OAuth2.IdentityChange + end + read :sign_in_with_oauth2 do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false @@ -231,6 +242,16 @@ defmodule Example.User do Logger.debug("Magic link request for #{user.username}, token #{inspect(token)}") end end + + oidc do + authorization_params scope: "openid profile email phone address" + authorize_url &get_config/2 + client_id &get_config/2 + client_secret &get_config/2 + redirect_uri &get_config/2 + site &get_config/2 + token_url &get_config/2 + end end end