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:
James Harton 2023-05-04 13:15:24 +12:00 committed by GitHub
parent 40f18364cb
commit 53ff256391
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 556 additions and 201 deletions

View file

@ -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,

View file

@ -2,8 +2,6 @@ name: Elixir Library
on:
push:
pull_request:
branches: [main]
jobs:
deps:

View file

@ -3,7 +3,10 @@
"defimpl",
"defstruct",
"ilike",
"Joken",
"Marties",
"moduledocs"
"moduledocs",
"oidc",
"unguessable"
]
}

View file

@ -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: [

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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"},

View file

@ -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,

View file

@ -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