mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
feat: OpenID Connect Strategy (#197)
* feat(AshAuthentication.Strategy.Oidc): Add OpenID Connect strategy. * chore(CI): disable the workflow on pull request event, since it's covered by push.
This commit is contained in:
parent
40f18364cb
commit
53ff256391
28 changed files with 556 additions and 201 deletions
|
@ -8,6 +8,7 @@ spark_locals_without_parens = [
|
||||||
auth_method: 1,
|
auth_method: 1,
|
||||||
authorization_params: 1,
|
authorization_params: 1,
|
||||||
authorize_url: 1,
|
authorize_url: 1,
|
||||||
|
client_authentication_method: 1,
|
||||||
client_id: 1,
|
client_id: 1,
|
||||||
client_secret: 1,
|
client_secret: 1,
|
||||||
confirm_action_name: 1,
|
confirm_action_name: 1,
|
||||||
|
@ -30,6 +31,9 @@ spark_locals_without_parens = [
|
||||||
github: 2,
|
github: 2,
|
||||||
hash_provider: 1,
|
hash_provider: 1,
|
||||||
hashed_password_field: 1,
|
hashed_password_field: 1,
|
||||||
|
icon: 1,
|
||||||
|
id_token_signed_response_alg: 1,
|
||||||
|
id_token_ttl_seconds: 1,
|
||||||
identity_field: 1,
|
identity_field: 1,
|
||||||
identity_relationship_name: 1,
|
identity_relationship_name: 1,
|
||||||
identity_relationship_user_id_attribute: 1,
|
identity_relationship_user_id_attribute: 1,
|
||||||
|
@ -40,9 +44,15 @@ spark_locals_without_parens = [
|
||||||
magic_link: 1,
|
magic_link: 1,
|
||||||
magic_link: 2,
|
magic_link: 2,
|
||||||
monitor_fields: 1,
|
monitor_fields: 1,
|
||||||
|
nonce: 1,
|
||||||
oauth2: 0,
|
oauth2: 0,
|
||||||
oauth2: 1,
|
oauth2: 1,
|
||||||
oauth2: 2,
|
oauth2: 2,
|
||||||
|
oidc: 0,
|
||||||
|
oidc: 1,
|
||||||
|
oidc: 2,
|
||||||
|
openid_configuration: 1,
|
||||||
|
openid_configuration_uri: 1,
|
||||||
password: 0,
|
password: 0,
|
||||||
password: 1,
|
password: 1,
|
||||||
password: 2,
|
password: 2,
|
||||||
|
@ -82,6 +92,7 @@ spark_locals_without_parens = [
|
||||||
token_param_name: 1,
|
token_param_name: 1,
|
||||||
token_resource: 1,
|
token_resource: 1,
|
||||||
token_url: 1,
|
token_url: 1,
|
||||||
|
trusted_audiences: 1,
|
||||||
uid_attribute_name: 1,
|
uid_attribute_name: 1,
|
||||||
upsert_action_name: 1,
|
upsert_action_name: 1,
|
||||||
user_id_attribute_name: 1,
|
user_id_attribute_name: 1,
|
||||||
|
|
2
.github/workflows/elixir_lib.yml
vendored
2
.github/workflows/elixir_lib.yml
vendored
|
@ -2,8 +2,6 @@ name: Elixir Library
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deps:
|
deps:
|
||||||
|
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -3,7 +3,10 @@
|
||||||
"defimpl",
|
"defimpl",
|
||||||
"defstruct",
|
"defstruct",
|
||||||
"ilike",
|
"ilike",
|
||||||
|
"Joken",
|
||||||
"Marties",
|
"Marties",
|
||||||
"moduledocs"
|
"moduledocs",
|
||||||
|
"oidc",
|
||||||
|
"unguessable"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,14 @@ config :ash_authentication,
|
||||||
client_id: System.get_env("GITHUB_CLIENT_ID"),
|
client_id: System.get_env("GITHUB_CLIENT_ID"),
|
||||||
client_secret: System.get_env("GITHUB_CLIENT_SECRET"),
|
client_secret: System.get_env("GITHUB_CLIENT_SECRET"),
|
||||||
redirect_uri: "http://localhost:4000/auth"
|
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: [
|
tokens: [
|
||||||
|
|
|
@ -141,6 +141,10 @@ defmodule DevServer.TestPage do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_strategy(strategy, phase, _)
|
||||||
|
when strategy.provider == :password and phase == :sign_in_with_token,
|
||||||
|
do: ""
|
||||||
|
|
||||||
defp render_strategy(strategy, phase, options)
|
defp render_strategy(strategy, phase, options)
|
||||||
when strategy.provider == :confirmation and phase == :confirm do
|
when strategy.provider == :confirmation and phase == :confirm do
|
||||||
EEx.eval_string(
|
EEx.eval_string(
|
||||||
|
|
|
@ -103,25 +103,23 @@ defmodule AshAuthentication do
|
||||||
Resource
|
Resource
|
||||||
}
|
}
|
||||||
|
|
||||||
alias AshAuthentication.{
|
alias AshAuthentication.Info
|
||||||
AddOn.Confirmation,
|
|
||||||
Info,
|
|
||||||
Strategy.Auth0,
|
|
||||||
Strategy.Github,
|
|
||||||
Strategy.MagicLink,
|
|
||||||
Strategy.OAuth2,
|
|
||||||
Strategy.Password
|
|
||||||
}
|
|
||||||
|
|
||||||
alias Spark.Dsl.Extension
|
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,
|
use Spark.Dsl.Extension,
|
||||||
sections: dsl(),
|
sections: dsl(),
|
||||||
dsl_patches:
|
dsl_patches: Enum.flat_map(@built_in_strategies, & &1.dsl_patches()),
|
||||||
Enum.flat_map(
|
|
||||||
[Confirmation, Auth0, Github, OAuth2, Password, MagicLink],
|
|
||||||
& &1.dsl_patches()
|
|
||||||
),
|
|
||||||
transformers: [
|
transformers: [
|
||||||
AshAuthentication.Transformer,
|
AshAuthentication.Transformer,
|
||||||
AshAuthentication.Transformer.SetSelectForSenders,
|
AshAuthentication.Transformer.SetSelectForSenders,
|
||||||
|
@ -236,4 +234,8 @@ defmodule AshAuthentication do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec __built_in_strategies__ :: [module]
|
||||||
|
def __built_in_strategies__, do: @built_in_strategies
|
||||||
end
|
end
|
||||||
|
|
|
@ -92,17 +92,18 @@ defmodule AshAuthentication.AddOn.Confirmation do
|
||||||
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
|
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
defstruct token_lifetime: nil,
|
defstruct confirm_action_name: :confirm,
|
||||||
monitor_fields: [],
|
|
||||||
confirmed_at_field: :confirmed_at,
|
|
||||||
confirm_on_create?: true,
|
confirm_on_create?: true,
|
||||||
confirm_on_update?: true,
|
confirm_on_update?: true,
|
||||||
|
confirmed_at_field: :confirmed_at,
|
||||||
inhibit_updates?: false,
|
inhibit_updates?: false,
|
||||||
sender: nil,
|
monitor_fields: [],
|
||||||
confirm_action_name: :confirm,
|
name: :confirm,
|
||||||
resource: nil,
|
|
||||||
provider: :confirmation,
|
provider: :confirmation,
|
||||||
name: :confirm
|
resource: nil,
|
||||||
|
sender: nil,
|
||||||
|
strategy_module: __MODULE__,
|
||||||
|
token_lifetime: nil
|
||||||
|
|
||||||
alias Ash.{Changeset, Resource}
|
alias Ash.{Changeset, Resource}
|
||||||
alias AshAuthentication.{AddOn.Confirmation, Jwt, Strategy.Custom}
|
alias AshAuthentication.{AddOn.Confirmation, Jwt, Strategy.Custom}
|
||||||
|
@ -110,17 +111,18 @@ defmodule AshAuthentication.AddOn.Confirmation do
|
||||||
use Custom, style: :add_on, entity: Dsl.dsl()
|
use Custom, style: :add_on, entity: Dsl.dsl()
|
||||||
|
|
||||||
@type t :: %Confirmation{
|
@type t :: %Confirmation{
|
||||||
token_lifetime: hours :: pos_integer,
|
confirm_action_name: atom,
|
||||||
monitor_fields: [atom],
|
|
||||||
confirmed_at_field: atom,
|
|
||||||
confirm_on_create?: boolean,
|
confirm_on_create?: boolean,
|
||||||
confirm_on_update?: boolean,
|
confirm_on_update?: boolean,
|
||||||
|
confirmed_at_field: atom,
|
||||||
inhibit_updates?: boolean,
|
inhibit_updates?: boolean,
|
||||||
sender: nil | {module, keyword},
|
monitor_fields: [atom],
|
||||||
confirm_action_name: atom,
|
name: :confirm,
|
||||||
resource: module,
|
|
||||||
provider: :confirmation,
|
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
|
defdelegate transform(strategy, dsl_state), to: Transformer
|
||||||
|
|
|
@ -13,7 +13,7 @@ defmodule AshAuthentication.Strategy.Auth0.Dsl do
|
||||||
describe: """
|
describe: """
|
||||||
Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/).
|
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.
|
configuration options should you need them.
|
||||||
|
|
||||||
For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md)
|
For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md)
|
||||||
|
|
|
@ -13,10 +13,21 @@ defmodule AshAuthentication.Strategy.Custom do
|
||||||
|
|
||||||
See `Spark.Dsl.Entity` for more information.
|
See `Spark.Dsl.Entity` for more information.
|
||||||
"""
|
"""
|
||||||
# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
|
@type entity :: Spark.Dsl.Entity.t()
|
||||||
@type entity :: %Dsl.Entity{}
|
|
||||||
|
|
||||||
@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 """
|
@doc """
|
||||||
If your strategy needs to modify either the entity or the parent resource then
|
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)
|
|> Keyword.get(:entity)
|
||||||
|> case do
|
|> case do
|
||||||
%Dsl.Entity{} = entity ->
|
%Dsl.Entity{} = entity ->
|
||||||
|
%{
|
||||||
entity
|
entity
|
||||||
|
| auto_set_fields:
|
||||||
|
Keyword.merge([strategy_module: __MODULE__], entity.auto_set_fields || [])
|
||||||
|
}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
raise CompileError,
|
raise CompileError,
|
||||||
|
|
|
@ -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
|
|
|
@ -32,62 +32,62 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do
|
||||||
| {:warn, map(), String.t() | [String.t()]}
|
| {:warn, map(), String.t() | [String.t()]}
|
||||||
| :halt
|
| :halt
|
||||||
def transform(dsl_state) do
|
def transform(dsl_state) do
|
||||||
strategy_to_target =
|
with {:ok, dsl_state} <- do_strategy_transforms(dsl_state) do
|
||||||
:code.all_available()
|
do_add_on_transforms(dsl_state)
|
||||||
|> 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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_strategy_transforms(dsl_state, strategy_to_target) do
|
defp do_strategy_transforms(dsl_state) do
|
||||||
dsl_state
|
dsl_state
|
||||||
|> Info.authentication_strategies()
|
|> Info.authentication_strategies()
|
||||||
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
|
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
|
||||||
strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__)
|
case do_transform(strategy, dsl_state, :strategy) do
|
||||||
|
|
||||||
case do_transform(strategy_module, strategy, dsl_state, :strategy) do
|
|
||||||
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
|
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
|
||||||
{:error, reason} -> {:halt, {:error, reason}}
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_add_on_transforms(dsl_state, strategy_to_target) do
|
defp do_add_on_transforms(dsl_state) do
|
||||||
dsl_state
|
dsl_state
|
||||||
|> Info.authentication_add_ons()
|
|> Info.authentication_add_ons()
|
||||||
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
|
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
|
||||||
strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__)
|
case do_transform(strategy, dsl_state, :add_on) do
|
||||||
|
|
||||||
case do_transform(strategy_module, strategy, dsl_state, :add_on) do
|
|
||||||
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
|
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
|
||||||
{:error, reason} -> {:halt, {:error, reason}}
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_transform(strategy_module, strategy, dsl_state, :strategy)
|
defp do_transform(strategy, _, _) when not is_map_key(strategy, :strategy_module) do
|
||||||
when 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 `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)}
|
strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)}
|
||||||
dsl_state = put_strategy(dsl_state, strategy)
|
dsl_state = put_strategy(dsl_state, strategy)
|
||||||
entity_module = strategy.__struct__
|
entity_module = strategy.__struct__
|
||||||
|
|
||||||
strategy
|
strategy
|
||||||
|> strategy_module.transform(dsl_state)
|
|> strategy.strategy_module.transform(dsl_state)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, strategy} when is_struct(strategy, entity_module) ->
|
{:ok, strategy} when is_struct(strategy, entity_module) ->
|
||||||
{:ok, put_strategy(dsl_state, strategy)}
|
{:ok, put_strategy(dsl_state, strategy)}
|
||||||
|
@ -100,14 +100,13 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_transform(strategy_module, strategy, dsl_state, :add_on)
|
defp do_transform(strategy, dsl_state, :add_on) do
|
||||||
when is_map_key(strategy, :resource) do
|
|
||||||
strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)}
|
strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)}
|
||||||
dsl_state = put_add_on(dsl_state, strategy)
|
dsl_state = put_add_on(dsl_state, strategy)
|
||||||
entity_module = strategy.__struct__
|
entity_module = strategy.__struct__
|
||||||
|
|
||||||
strategy
|
strategy
|
||||||
|> strategy_module.transform(dsl_state)
|
|> strategy.strategy_module.transform(dsl_state)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, strategy} when is_struct(strategy, entity_module) ->
|
{:ok, strategy} when is_struct(strategy, entity_module) ->
|
||||||
{:ok, put_add_on(dsl_state, strategy)}
|
{:ok, put_add_on(dsl_state, strategy)}
|
||||||
|
@ -119,15 +118,4 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -8,7 +8,6 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do
|
||||||
use Spark.Dsl.Verifier
|
use Spark.Dsl.Verifier
|
||||||
|
|
||||||
alias AshAuthentication.Info
|
alias AshAuthentication.Info
|
||||||
alias Spark.Dsl.Transformer
|
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@impl true
|
@impl true
|
||||||
|
@ -17,18 +16,13 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do
|
||||||
| {:error, term}
|
| {:error, term}
|
||||||
| {:warn, String.t() | list(String.t())}
|
| {:warn, String.t() | list(String.t())}
|
||||||
def verify(dsl_state) do
|
def verify(dsl_state) do
|
||||||
strategy_to_target =
|
|
||||||
dsl_state
|
|
||||||
|> Transformer.get_persisted(:ash_authentication_strategy_to_target, %{})
|
|
||||||
|
|
||||||
dsl_state
|
dsl_state
|
||||||
|> Info.authentication_strategies()
|
|> Info.authentication_strategies()
|
||||||
|> Stream.concat(Info.authentication_add_ons(dsl_state))
|
|> Stream.concat(Info.authentication_add_ons(dsl_state))
|
||||||
|> Enum.reduce_while(:ok, fn strategy, :ok ->
|
|> Enum.reduce_while(:ok, fn
|
||||||
strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__)
|
strategy, :ok ->
|
||||||
|
|
||||||
strategy
|
strategy
|
||||||
|> strategy_module.verify(dsl_state)
|
|> strategy.strategy_module.verify(dsl_state)
|
||||||
|> case do
|
|> case do
|
||||||
:ok -> {:cont, :ok}
|
:ok -> {:cont, :ok}
|
||||||
{:error, reason} -> {:halt, {:error, reason}}
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
|
|
|
@ -13,7 +13,7 @@ defmodule AshAuthentication.Strategy.Github.Dsl do
|
||||||
describe: """
|
describe: """
|
||||||
Provides a pre-configured authentication strategy for [GitHub](https://github.com/).
|
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.
|
configuration options should you need them.
|
||||||
|
|
||||||
For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md)
|
For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md)
|
||||||
|
|
|
@ -107,6 +107,7 @@ defmodule AshAuthentication.Strategy.MagicLink do
|
||||||
sender: nil,
|
sender: nil,
|
||||||
sign_in_action_name: nil,
|
sign_in_action_name: nil,
|
||||||
single_use_token?: true,
|
single_use_token?: true,
|
||||||
|
strategy_module: __MODULE__,
|
||||||
token_lifetime: 10,
|
token_lifetime: 10,
|
||||||
token_param_name: :token
|
token_param_name: :token
|
||||||
|
|
||||||
|
@ -123,6 +124,7 @@ defmodule AshAuthentication.Strategy.MagicLink do
|
||||||
sender: {module, keyword},
|
sender: {module, keyword},
|
||||||
single_use_token?: boolean,
|
single_use_token?: boolean,
|
||||||
sign_in_action_name: atom,
|
sign_in_action_name: atom,
|
||||||
|
strategy_module: module,
|
||||||
token_lifetime: pos_integer(),
|
token_lifetime: pos_integer(),
|
||||||
token_param_name: atom
|
token_param_name: atom
|
||||||
}
|
}
|
||||||
|
|
|
@ -219,27 +219,39 @@ defmodule AshAuthentication.Strategy.OAuth2 do
|
||||||
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
|
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
defstruct client_id: nil,
|
@struct_fields [
|
||||||
site: nil,
|
assent_strategy: Assent.Strategy.OAuth2,
|
||||||
auth_method: :client_secret_post,
|
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: [],
|
authorization_params: [],
|
||||||
registration_enabled?: true,
|
authorize_url: nil,
|
||||||
register_action_name: nil,
|
client_authentication_method: nil,
|
||||||
sign_in_action_name: nil,
|
client_id: nil,
|
||||||
identity_resource: false,
|
client_secret: nil,
|
||||||
|
icon: nil,
|
||||||
|
id_token_signed_response_alg: nil,
|
||||||
|
id_token_ttl_seconds: nil,
|
||||||
identity_relationship_name: :identities,
|
identity_relationship_name: :identities,
|
||||||
identity_relationship_user_id_attribute: :user_id,
|
identity_relationship_user_id_attribute: :user_id,
|
||||||
provider: :oauth2,
|
identity_resource: false,
|
||||||
name: nil,
|
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,
|
resource: nil,
|
||||||
icon: nil,
|
sign_in_action_name: nil,
|
||||||
assent_strategy: Assent.Strategy.OAuth2
|
site: nil,
|
||||||
|
strategy_module: __MODULE__,
|
||||||
|
token_url: nil,
|
||||||
|
trusted_audiences: nil,
|
||||||
|
user_url: nil
|
||||||
|
]
|
||||||
|
|
||||||
|
defstruct @struct_fields
|
||||||
|
|
||||||
alias AshAuthentication.Strategy.{Custom, OAuth2}
|
alias AshAuthentication.Strategy.{Custom, OAuth2}
|
||||||
|
|
||||||
|
@ -248,32 +260,40 @@ defmodule AshAuthentication.Strategy.OAuth2 do
|
||||||
@type secret :: nil | String.t() | {module, keyword}
|
@type secret :: nil | String.t() | {module, keyword}
|
||||||
|
|
||||||
@type t :: %OAuth2{
|
@type t :: %OAuth2{
|
||||||
client_id: secret,
|
assent_strategy: module,
|
||||||
site: secret,
|
|
||||||
auth_method:
|
auth_method:
|
||||||
nil
|
nil
|
||||||
| :client_secret_basic
|
| :client_secret_basic
|
||||||
| :client_secret_post
|
| :client_secret_post
|
||||||
| :client_secret_jwt
|
| :client_secret_jwt
|
||||||
| :private_key_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,
|
authorization_params: keyword,
|
||||||
registration_enabled?: boolean,
|
authorize_url: secret,
|
||||||
register_action_name: atom,
|
client_authentication_method: nil | atom,
|
||||||
sign_in_action_name: atom,
|
client_id: secret,
|
||||||
identity_resource: module | false,
|
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_name: atom,
|
||||||
identity_relationship_user_id_attribute: atom,
|
identity_relationship_user_id_attribute: atom,
|
||||||
provider: atom,
|
identity_resource: module | false,
|
||||||
name: atom,
|
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,
|
resource: module,
|
||||||
icon: nil | atom,
|
sign_in_action_name: atom,
|
||||||
assent_strategy: module
|
site: secret,
|
||||||
|
strategy_module: module,
|
||||||
|
token_url: secret,
|
||||||
|
trusted_audiences: nil | [binary],
|
||||||
|
user_url: secret
|
||||||
}
|
}
|
||||||
|
|
||||||
defdelegate dsl, to: Dsl
|
defdelegate dsl, to: Dsl
|
||||||
|
|
|
@ -284,6 +284,16 @@ defmodule AshAuthentication.Strategy.OAuth2.Dsl do
|
||||||
`user_id_attribute_name` option of the provider identity.
|
`user_id_attribute_name` option of the provider identity.
|
||||||
""",
|
""",
|
||||||
default: :user_id
|
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]
|
auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2]
|
||||||
|
|
|
@ -11,6 +11,15 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
|
||||||
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
|
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
|
||||||
import Plug.Conn
|
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 """
|
@doc """
|
||||||
Perform the request phase of OAuth2.
|
Perform the request phase of OAuth2.
|
||||||
|
|
||||||
|
@ -20,6 +29,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
|
||||||
@spec request(Conn.t(), OAuth2.t()) :: Conn.t()
|
@spec request(Conn.t(), OAuth2.t()) :: Conn.t()
|
||||||
def request(conn, strategy) do
|
def request(conn, strategy) do
|
||||||
with {:ok, config} <- config_for(strategy),
|
with {:ok, config} <- config_for(strategy),
|
||||||
|
{:ok, config} <- maybe_add_nonce(config, strategy),
|
||||||
{:ok, session_key} <- session_key(strategy),
|
{:ok, session_key} <- session_key(strategy),
|
||||||
{:ok, %{session_params: session_params, url: url}} <-
|
{:ok, %{session_params: session_params, url: url}} <-
|
||||||
strategy.assent_strategy.authorize_url(config) do
|
strategy.assent_strategy.authorize_url(config) do
|
||||||
|
@ -68,27 +78,25 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp config_for(strategy) do
|
defp config_for(strategy) do
|
||||||
with {:ok, client_id} <- fetch_secret(strategy, :client_id),
|
|
||||||
{:ok, site} <- fetch_secret(strategy, :site),
|
|
||||||
{: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
|
|
||||||
config =
|
config =
|
||||||
[
|
strategy
|
||||||
auth_method: strategy.auth_method,
|
|> Map.take(@raw_config_attrs)
|
||||||
client_id: client_id,
|
|> Map.put(:http_adapter, Mint)
|
||||||
client_secret: get_secret(strategy, :client_secret),
|
|
||||||
private_key: get_secret(strategy, :private_key),
|
with {:ok, config} <- add_secret_value(config, strategy, :authorize_url),
|
||||||
jwt_algorithm: Info.authentication_tokens_signing_algorithm(strategy.resource),
|
{:ok, config} <- add_secret_value(config, strategy, :client_id),
|
||||||
authorization_params: strategy.authorization_params,
|
{:ok, config} <- add_secret_value(config, strategy, :client_secret),
|
||||||
redirect_uri: redirect_uri,
|
{:ok, config} <- add_secret_value(config, strategy, :site),
|
||||||
site: site,
|
{:ok, config} <- add_secret_value(config, strategy, :token_url),
|
||||||
authorize_url: authorize_url,
|
{:ok, config} <- add_secret_value(config, strategy, :user_url, !!strategy.authorize_url),
|
||||||
token_url: token_url,
|
{:ok, redirect_uri} <- build_redirect_uri(strategy),
|
||||||
user_url: user_url,
|
{:ok, jwt_algorithm} <-
|
||||||
http_adapter: Mint
|
Info.authentication_tokens_signing_algorithm(strategy.resource) do
|
||||||
]
|
config =
|
||||||
|
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)))
|
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||||
|
|
||||||
{:ok, config}
|
{:ok, config}
|
||||||
|
@ -116,6 +124,34 @@ defmodule AshAuthentication.Strategy.OAuth2.Plug do
|
||||||
end
|
end
|
||||||
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
|
defp fetch_secret(strategy, secret_name) do
|
||||||
path = [:authentication, :strategies, strategy.name, secret_name]
|
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
|
secret_module.secret_for(path, strategy.resource, secret_opts) do
|
||||||
{:ok, secret}
|
{:ok, secret}
|
||||||
else
|
else
|
||||||
{:ok, secret} when is_binary(secret) -> {:ok, secret}
|
{:ok, secret} ->
|
||||||
_ -> {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)}
|
{:ok, secret}
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_secret(strategy, secret_name) do
|
_ ->
|
||||||
case fetch_secret(strategy, secret_name) do
|
{:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)}
|
||||||
{:ok, secret} -> secret
|
|
||||||
_ -> nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
|
||||||
DSL verifier for oauth2 strategies.
|
DSL verifier for oauth2 strategies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias AshAuthentication.{Secret, Strategy.OAuth2}
|
alias AshAuthentication.Strategy.OAuth2
|
||||||
alias Spark.Error.DslError
|
|
||||||
import AshAuthentication.Validations
|
import AshAuthentication.Validations
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
@ -17,28 +16,11 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
|
||||||
:ok <- validate_secret(strategy, :site),
|
:ok <- validate_secret(strategy, :site),
|
||||||
:ok <- validate_secret(strategy, :token_url),
|
:ok <- validate_secret(strategy, :token_url),
|
||||||
:ok <- validate_secret(strategy, :user_url) do
|
:ok <- validate_secret(strategy, :user_url) do
|
||||||
validate_secret(strategy, :private_key, strategy.auth_method != :private_key_jwt)
|
if strategy.auth_method == :private_key_jwt do
|
||||||
end
|
validate_secret(strategy, :private_key)
|
||||||
end
|
else
|
||||||
|
|
||||||
defp validate_secret(strategy, option, allow_nil \\ false) do
|
|
||||||
case Map.fetch(strategy, option) do
|
|
||||||
{:ok, value} when is_binary(value) ->
|
|
||||||
:ok
|
:ok
|
||||||
|
end
|
||||||
{: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
|
||||||
end
|
end
|
||||||
|
|
60
lib/ash_authentication/strategies/oidc.ex
Normal file
60
lib/ash_authentication/strategies/oidc.ex
Normal file
|
@ -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
|
104
lib/ash_authentication/strategies/oidc/dsl.ex
Normal file
104
lib/ash_authentication/strategies/oidc/dsl.ex
Normal file
|
@ -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
|
29
lib/ash_authentication/strategies/oidc/nonce_generator.ex
Normal file
29
lib/ash_authentication/strategies/oidc/nonce_generator.ex
Normal file
|
@ -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
|
20
lib/ash_authentication/strategies/oidc/transformer.ex
Normal file
20
lib/ash_authentication/strategies/oidc/transformer.ex
Normal file
|
@ -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
|
27
lib/ash_authentication/strategies/oidc/verifier.ex
Normal file
27
lib/ash_authentication/strategies/oidc/verifier.ex
Normal file
|
@ -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
|
|
@ -112,7 +112,8 @@ defmodule AshAuthentication.Strategy.Password do
|
||||||
sign_in_enabled?: true,
|
sign_in_enabled?: true,
|
||||||
sign_in_token_lifetime: 60,
|
sign_in_token_lifetime: 60,
|
||||||
sign_in_tokens_enabled?: false,
|
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
|
alias Ash.Resource
|
||||||
|
|
||||||
|
@ -145,7 +146,8 @@ defmodule AshAuthentication.Strategy.Password do
|
||||||
sign_in_enabled?: boolean,
|
sign_in_enabled?: boolean,
|
||||||
sign_in_token_lifetime: pos_integer,
|
sign_in_token_lifetime: pos_integer,
|
||||||
sign_in_tokens_enabled?: boolean,
|
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
|
defdelegate dsl(), to: Dsl
|
||||||
|
|
|
@ -165,4 +165,37 @@ defmodule AshAuthentication.Validations do
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
2
mix.exs
2
mix.exs
|
@ -146,7 +146,7 @@ defmodule AshAuthentication.MixProject do
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:ash, ash_version("~> 2.5 and >= 2.5.11")},
|
{:ash, ash_version("~> 2.5 and >= 2.5.11")},
|
||||||
{:spark, "~> 1.0"},
|
{:spark, "~> 1.0 and >= 1.0.9"},
|
||||||
{:jason, "~> 1.4"},
|
{:jason, "~> 1.4"},
|
||||||
{:joken, "~> 2.5"},
|
{:joken, "~> 2.5"},
|
||||||
{:plug, "~> 1.13"},
|
{:plug, "~> 1.13"},
|
||||||
|
|
|
@ -3,7 +3,11 @@ defmodule Example.OnlyMartiesAtTheParty do
|
||||||
A really dumb custom strategy that lets anyone named Marty sign in.
|
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{
|
@entity %Spark.Dsl.Entity{
|
||||||
name: :only_marty,
|
name: :only_marty,
|
||||||
|
|
|
@ -73,6 +73,17 @@ defmodule Example.User do
|
||||||
change AshAuthentication.Strategy.OAuth2.IdentityChange
|
change AshAuthentication.Strategy.OAuth2.IdentityChange
|
||||||
end
|
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
|
read :sign_in_with_oauth2 do
|
||||||
argument :user_info, :map, allow_nil?: false
|
argument :user_info, :map, allow_nil?: false
|
||||||
argument :oauth_tokens, :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)}")
|
Logger.debug("Magic link request for #{user.username}, token #{inspect(token)}")
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue