From 371a6ad8219692029b0f009269b7122d7b23cc08 Mon Sep 17 00:00:00 2001 From: James Harton Date: Fri, 9 Dec 2022 11:32:34 +1300 Subject: [PATCH] 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. --- config/dev.exs | 6 +- config/test.exs | 6 +- .../getting_started_01_basic_setup.md | 32 +++++++- lib/ash_authentication/dsl.ex | 8 ++ lib/ash_authentication/jwt.ex | 45 +++++++---- lib/ash_authentication/jwt/config.ex | 75 ++++++++++--------- lib/ash_authentication/secret.ex | 2 +- lib/ash_authentication/spark_doc_index.ex | 4 + test/support/example/user.ex | 1 + 9 files changed, 121 insertions(+), 58 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index 692af03..53e11ef 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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" ] ] diff --git a/config/test.exs b/config/test.exs index 29f57f9..9f152e1 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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" ] ] diff --git a/documentation/getting_started/getting_started_01_basic_setup.md b/documentation/getting_started/getting_started_01_basic_setup.md index 2dd2287..43bc4fb 100644 --- a/documentation/getting_started/getting_started_01_basic_setup.md +++ b/documentation/getting_started/getting_started_01_basic_setup.md @@ -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 diff --git a/lib/ash_authentication/dsl.ex b/lib/ash_authentication/dsl.ex index 4059dbd..0b305cb 100644 --- a/lib/ash_authentication/dsl.ex +++ b/lib/ash_authentication/dsl.ex @@ -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} + """ ] ] }, diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex index f5ad0bb..96fc0b1 100644 --- a/lib/ash_authentication/jwt.ex +++ b/lib/ash_authentication/jwt.ex @@ -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 diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex index b0fdf10..10bbdca 100644 --- a/lib/ash_authentication/jwt/config.ex +++ b/lib/ash_authentication/jwt/config.ex @@ -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 diff --git a/lib/ash_authentication/secret.ex b/lib/ash_authentication/secret.ex index 16de21a..abee2bc 100644 --- a/lib/ash_authentication/secret.ex +++ b/lib/ash_authentication/secret.ex @@ -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 diff --git a/lib/ash_authentication/spark_doc_index.ex b/lib/ash_authentication/spark_doc_index.ex index c75262a..e07729a 100644 --- a/lib/ash_authentication/spark_doc_index.ex +++ b/lib/ash_authentication/spark_doc_index.ex @@ -56,6 +56,10 @@ defmodule AshAuthentication.SparkDocIndex do AshAuthentication.Strategy, AshAuthentication.Strategy.Password, AshAuthentication.Strategy.OAuth2 + ]}, + {"Add Ons", + [ + AshAuthentication.AddOn.Confirmation ]} ] end diff --git a/test/support/example/user.ex b/test/support/example/user.ex index 7999a78..09d3f86 100644 --- a/test/support/example/user.ex +++ b/test/support/example/user.ex @@ -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