mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-08 05:40:58 +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,
|
||||
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,
|
||||
|
|
2
.github/workflows/elixir_lib.yml
vendored
2
.github/workflows/elixir_lib.yml
vendored
|
@ -2,8 +2,6 @@ name: Elixir Library
|
|||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deps:
|
||||
|
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -3,7 +3,10 @@
|
|||
"defimpl",
|
||||
"defstruct",
|
||||
"ilike",
|
||||
"Joken",
|
||||
"Marties",
|
||||
"moduledocs"
|
||||
"moduledocs",
|
||||
"oidc",
|
||||
"unguessable"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()]}
|
||||
| :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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
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_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
|
||||
|
|
|
@ -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
|
||||
|
|
2
mix.exs
2
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"},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue