improvement(Jwt)!: Use token signing secret into the DSL.

Use the `AshAuthentication.Secret` behaviour, rather than asking the user to explicitly set it in their application environment.

This is a breaking change that will require folks to change their resource config to look up the signing secret.

Closes #79.
Closes #77.
This commit is contained in:
James Harton 2022-12-09 11:32:34 +13:00 committed by James Harton
parent 6977c39990
commit 371a6ad821
9 changed files with 121 additions and 58 deletions

View file

@ -26,9 +26,6 @@ config :ash_authentication, Example,
registry: Example.Registry
]
config :ash_authentication, AshAuthentication.Jwt,
signing_secret: "Marty McFly in the past with the Delorean"
config :ash_authentication,
authentication: [
strategies: [
@ -41,5 +38,8 @@ config :ash_authentication,
token_path: "/oauth/token",
user_path: "/userinfo"
]
],
tokens: [
signing_secret: "Marty McFly in the past with the Delorean"
]
]

View file

@ -19,9 +19,6 @@ config :bcrypt_elixir, :log_rounds, 4
config :ash, :disable_async?, true
config :ash_authentication, AshAuthentication.Jwt,
signing_secret: "Marty McFly in the past with the Delorean"
config :ash_authentication,
authentication: [
strategies: [
@ -34,5 +31,8 @@ config :ash_authentication,
token_path: "/oauth/token",
user_path: "/userinfo"
]
],
tokens: [
signing_secret: "Marty McFly in the past with the Delorean"
]
]

View file

@ -116,7 +116,10 @@ end
Next, let's define our `Token` resource. This resource is needed
if token generation is enabled for any resources in your application. Most of
the contents are auto-generated, so we just need to provide the data layer
configuration and the API to use:
configuration and the API to use.
You can skip this step if you don't want to use tokens, in which case remove the
`tokens` DSL section in the user resource below.
```elixir
# lib/my_app/accounts/token.ex
@ -137,7 +140,8 @@ defmodule MyApp.Accounts.Token do
end
```
Lastly let's define our `User` resource, using password authentication and token generation enabled.
Lastly let's define our `User` resource, using password authentication and token
generation enabled.
```elixir
# lib/my_app/accounts/user.ex
@ -165,6 +169,9 @@ defmodule MyApp.Accounts.User do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret fn _, _ ->
Application.fetch_env(:my_app, :token_signing_secret)
end
do
end
@ -187,7 +194,8 @@ Now we have enough in place to register and sign-in users using the
## Plugs and routing
If you're using Phoenix, then you can skip this section and go straight to {{link:ash_authentication:guide:getting_started_02_phoenix|Using with Phoenix}}
If you're using Phoenix, then you can skip this section and go straight to
{{link:ash_authentication:guide:getting_started_02_phoenix|Using with Phoenix}}
In order for your users to be able to sign in, you will likely need to provide
an HTTP endpoint to submit credentials or OAuth requests to. Ash Authentication
@ -268,6 +276,24 @@ defmodule MyApp.Application do
end
```
## Token generation
If you have token generation enabled then you need to provide (at minimum) a
signing secret. As the name implies this should be a secret. AshAuthentication
provides a mechanism for looking up secrets at runtime using the
`AshAuthentication.Secret` behaviour. To save you a click, this means that you
can set your token signing secret using either a static string (please don't!),
a two-arity anonymous function, or a module which implements the
`AshAuthentication.Secret` behaviour.
At it's simplest you should so something like this:
```
signing_secret fn _, _ ->
Application.fetch_env(:my_app, :token_signing_secret)
end
```
## Summary
In this guide we've learned how to install Ash Authentication, configure

View file

@ -149,6 +149,14 @@ defmodule AshAuthentication.Dsl do
confirmations.
""",
required: true
],
signing_secret: [
type: @secret_type,
doc: """
The secret used to sign tokens.
#{@secret_doc}
"""
]
]
},

View file

@ -12,30 +12,47 @@ defmodule AshAuthentication.Jwt do
There are a few things we need to know in order to generate and sign a JWT:
* `signing_algorithm` - the crypographic algorithm used to to sign tokens.
Instance-wide configuration is configured by the application environment,
but can be overriden on a per-resource basis.
* `token_lifetime` - how long the token is valid for (in hours).
Instance-wide configuration is configured by the application environment,
but can be overriden on a per-resource basis.
* `signing_secret` - the secret key used to sign the tokens. Only
configurable via the application environment.
* `signing_secret` - the secret key used to sign the tokens.
These can be configured in your resource's token DSL:
```elixir
config :ash_authentication, #{inspect(__MODULE__)},
signing_algorithm: #{inspect(@default_algorithm)}
signing_secret: "I finally invent something that works!",
token_lifetime: #{@default_lifetime_days * 24} # #{@default_lifetime_days} days
defmodule MyApp.Accounts.User do
# ...
authentication do
tokens do
enabled? true
token_lifetime 32
signing_secret fn _, _ ->
System.fetch_env("TOKEN_SIGNING_SECRET")
end
end
end
# ...
end
```
The signing secret is retrieved using the `AshAuthentication.Secret`
behaviour, which means that it can be retrieved one of three ways:
1. As a string directly in your resource DSL (please don't do this unless you
know why this is a bad idea!), or
2. a two-arity anonymous function which returns `{:ok, secret}`, or
3. the name of a module which implements the `AshAuthentication.Secret`
behaviour.
Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}.
We strongly advise against storing the signing secret in your mix config. We
instead suggest you make use of
We strongly advise against storing the signing secret in your mix config or
directly in your resource configuration. We instead suggest you make use of
[`runtime.exs`](https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html#configuration)
and read it from the system environment or other secret store.
The default token lifetime is #{@default_lifetime_days * 24} and should be specified
in integer positive hours.
The default token lifetime is #{@default_lifetime_days * 24} and should be
specified in integer positive hours.
"""
alias Ash.Resource

View file

@ -16,10 +16,13 @@ defmodule AshAuthentication.Jwt.Config do
"""
@spec default_claims(Resource.t(), keyword) :: Joken.token_config()
def default_claims(resource, opts \\ []) do
config =
resource
|> config()
|> Keyword.merge(opts)
token_lifetime =
opts
|> Keyword.fetch(:token_lifetime)
|> case do
{:ok, hours} -> hours * 60 * 60
:error -> token_lifetime(resource)
end
{:ok, vsn} = :application.get_key(:ash_authentication, :vsn)
@ -28,7 +31,7 @@ defmodule AshAuthentication.Jwt.Config do
|> to_string()
|> Version.parse!()
Config.default_claims(default_exp: token_lifetime(config))
Config.default_claims(default_exp: token_lifetime)
|> Config.add_claim(
"iss",
fn -> generate_issuer(vsn) end,
@ -111,38 +114,42 @@ defmodule AshAuthentication.Jwt.Config do
"""
@spec token_signer(Resource.t(), keyword) :: Signer.t()
def token_signer(resource, opts \\ []) do
config =
resource
|> config()
|> Keyword.merge(opts)
algorithm =
with :error <- Keyword.fetch(opts, :signing_algorithm),
:error <- Info.authentication_tokens_signing_algorithm(resource) do
Jwt.default_algorithm()
else
{:ok, algorithm} -> algorithm
end
algorithm = Keyword.get_lazy(config, :signing_algorithm, &Jwt.default_algorithm/0)
signing_secret =
with :error <- Keyword.fetch(opts, :signing_secret),
{:ok, {secret_module, secret_opts}} <-
Info.authentication_tokens_signing_secret(resource),
{:ok, secret} <-
secret_module.secret_for(
~w[authentication tokens signing_secret]a,
resource,
secret_opts
) do
secret
else
{:ok, secret} when is_binary(secret) ->
secret
case Keyword.fetch(config, :signing_secret) do
{:ok, secret} ->
Signer.create(algorithm, secret)
_ ->
raise "Missing JWT signing secret. Please see the documentation for `AshAuthentication.Jwt` for details"
end
:error ->
raise "Missing JWT signing secret. Please see the documentation for `AshAuthentication.Jwt` for details"
Signer.create(algorithm, signing_secret)
end
defp token_lifetime(resource) do
resource
|> Info.authentication_tokens_token_lifetime()
|> case do
{:ok, hours} -> hours * 60 * 60
:error -> Jwt.default_lifetime_hrs() * 60 * 60
end
end
defp token_lifetime(config) do
hours =
config
|> Keyword.get_lazy(:token_lifetime, &Jwt.default_lifetime_hrs/0)
hours * 60 * 60
end
defp config(resource) do
config =
resource
|> Info.authentication_tokens_options()
|> Enum.reject(&is_nil(elem(&1, 1)))
:ash_authentication
|> Application.get_env(Jwt, [])
|> Keyword.merge(config)
end
end

View file

@ -41,7 +41,7 @@ defmodule AshAuthentication.Secret do
strategies do
oauth2 do
client_id fn _secret, _resource, _opts ->
client_id fn _secret, _resource ->
Application.fetch_env(:my_app, :oauth_client_id)
end
end

View file

@ -56,6 +56,10 @@ defmodule AshAuthentication.SparkDocIndex do
AshAuthentication.Strategy,
AshAuthentication.Strategy.Password,
AshAuthentication.Strategy.OAuth2
]},
{"Add Ons",
[
AshAuthentication.AddOn.Confirmation
]}
]
end

View file

@ -103,6 +103,7 @@ defmodule Example.User do
tokens do
enabled?(true)
token_resource(Example.Token)
signing_secret(&get_config/2)
end
add_ons do