From afa4a58229f1ed349e1f2607963ab0af377f2126 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 11:14:06 -0300 Subject: [PATCH 1/7] feat: add client assertions to authentication flows --- .../sessions/commands/inputs/get_session.ex | 2 +- .../lib/sessions/tokens/access_token.ex | 8 ++-- .../lib/sessions/tokens/client_assertion.ex | 30 +++++++++++++ .../sessions/tokens/hooks/validate_equalty.ex | 38 ++++++++++++++++ .../lib/sessions/tokens/refresh_token.ex | 8 ++-- .../sign_in/commands/client_credentials.ex | 27 ++++++++++-- .../commands/inputs/client_credentials.ex | 43 +++++++++++++++++-- .../sign_in/commands/inputs/resource_owner.ex | 41 +++++++++++++++++- .../lib/sign_in/commands/resource_owner.ex | 31 ++++++++++--- .../commands/client_credentials_test.exs | 7 +-- .../sign_in/commands/resource_owner_test.exs | 22 +++++++--- .../identity/schemas/client_application.ex | 15 +------ .../test/controllers/public/auth_test.exs | 6 ++- 13 files changed, 232 insertions(+), 46 deletions(-) create mode 100644 apps/authenticator/lib/sessions/tokens/client_assertion.ex create mode 100644 apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex diff --git a/apps/authenticator/lib/sessions/commands/inputs/get_session.ex b/apps/authenticator/lib/sessions/commands/inputs/get_session.ex index 16a7575..363c1d3 100644 --- a/apps/authenticator/lib/sessions/commands/inputs/get_session.ex +++ b/apps/authenticator/lib/sessions/commands/inputs/get_session.ex @@ -38,6 +38,6 @@ defmodule Authenticator.Sessions.Commands.Inputs.GetSession do end defp validate_emptiness(%{valid?: false} = changeset), do: changeset - defp validate_emptiness(%{valid?: true, changes: c} = set) when map_size(c) > 0, do: set + defp validate_emptiness(%{changes: chg} = changeset) when map_size(chg) > 0, do: changeset defp validate_emptiness(changeset), do: add_error(changeset, :jti, "All input fields are empty") end diff --git a/apps/authenticator/lib/sessions/tokens/access_token.ex b/apps/authenticator/lib/sessions/tokens/access_token.ex index 2430fed..a58d5bb 100644 --- a/apps/authenticator/lib/sessions/tokens/access_token.ex +++ b/apps/authenticator/lib/sessions/tokens/access_token.ex @@ -9,7 +9,7 @@ defmodule Authenticator.Sessions.Tokens.AccessToken do add_hook Authenticator.Sessions.Tokens.Hooks.ValidateUUID, ~w(sub aud) # Two hours in seconds - @max_expiration 60 * 60 * 2 + @max_exp 60 * 60 * 2 @default_issuer "WatcherEx" @default_type "Bearer" @@ -29,7 +29,7 @@ defmodule Authenticator.Sessions.Tokens.AccessToken do |> add_claim("scope", nil, &is_binary/1) end - defp gen_ttl, do: @max_expiration - defp gen_exp, do: Joken.current_time() + @max_expiration - defp valid_expiration?(exp), do: exp >= Joken.current_time() + defp gen_ttl, do: @max_exp + defp gen_exp, do: current_time() + @max_exp + defp valid_expiration?(exp), do: exp >= current_time() && exp <= current_time() + @max_exp end diff --git a/apps/authenticator/lib/sessions/tokens/client_assertion.ex b/apps/authenticator/lib/sessions/tokens/client_assertion.ex new file mode 100644 index 0000000..3100aff --- /dev/null +++ b/apps/authenticator/lib/sessions/tokens/client_assertion.ex @@ -0,0 +1,30 @@ +defmodule Authenticator.Sessions.Tokens.ClientAssertion do + @moduledoc """ + Client assertion token configurations. + """ + + use Joken.Config + + add_hook Joken.Hooks.RequiredClaims, ~w(exp iat nbf iss aud jti sub typ) + add_hook Authenticator.Sessions.Tokens.Hooks.ValidateUUID, ~w(sub aud) + add_hook Authenticator.Sessions.Tokens.Hooks.ValidateEqualty, ~w(sub iss) + + # Two hours in seconds + @max_exp 60 * 60 * 2 + + @default_issuer "WatcherEx" + @default_type "Bearer" + + @impl true + def token_config do + [iss: @default_issuer, skip: [:aud, :exp]] + |> default_claims() + |> add_claim("aud", & &1, &is_binary/1) + |> add_claim("exp", &gen_exp/0, fn exp, _, _ -> is_integer(exp) and valid_expiration?(exp) end) + |> add_claim("sub", & &1, fn value, _, ctx -> value == ctx.client_id end) + |> add_claim("typ", nil, fn value, _, _ -> value == @default_type end) + end + + defp gen_exp, do: current_time() + @max_exp + defp valid_expiration?(exp), do: exp >= current_time() && exp <= current_time() + @max_exp +end diff --git a/apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex b/apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex new file mode 100644 index 0000000..9414273 --- /dev/null +++ b/apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex @@ -0,0 +1,38 @@ +defmodule Authenticator.Sessions.Tokens.Hooks.ValidateEqualty do + @moduledoc """ + Helper to validate if a given claims is are iguals. + """ + + use Joken.Hooks + + @impl true + def after_validate([], _, _) do + raise "Missing iqualty claims options" + end + + def after_validate(claims_to_validate, _, _) when not is_list(claims_to_validate) do + raise "Options must be a list of claim keys" + end + + def after_validate(claims_to_validate, {:ok, claims} = result, input) do + keys = + claims_to_validate + |> Enum.map(&convert_keys/1) + |> MapSet.new() + + claims + |> Enum.filter(fn {key, _value} -> key in keys end) + |> Enum.uniq_by(fn {_key, value} -> value end) + |> case do + [] -> {:cont, result, input} + claims -> {:halt, {:error, [message: "Invalid token", invalid_equalty: get_keys(claims)]}} + end + end + + def after_validate(_, result, input), do: {:cont, result, input} + + defp convert_keys(key) when is_binary(key), do: key + defp convert_keys(key) when is_atom(key), do: Atom.to_string(key) + + defp get_keys(claims), do: Enum.map(claims, fn {key, _value} -> key end) +end diff --git a/apps/authenticator/lib/sessions/tokens/refresh_token.ex b/apps/authenticator/lib/sessions/tokens/refresh_token.ex index d489a4e..c327b46 100644 --- a/apps/authenticator/lib/sessions/tokens/refresh_token.ex +++ b/apps/authenticator/lib/sessions/tokens/refresh_token.ex @@ -9,7 +9,7 @@ defmodule Authenticator.Sessions.Tokens.RefreshToken do add_hook Authenticator.Sessions.Tokens.Hooks.ValidateUUID, ~w(aud) # Thirty days in seconds - @max_expiration 60 * 60 * 24 * 30 + @max_exp 60 * 60 * 24 * 30 @default_issuer "WatcherEx" @default_type "Bearer" @@ -25,7 +25,7 @@ defmodule Authenticator.Sessions.Tokens.RefreshToken do |> add_claim("ati", nil, &is_binary/1) end - defp gen_ttl, do: @max_expiration - defp gen_exp, do: Joken.current_time() + @max_expiration - defp valid_expiration?(exp), do: exp > Joken.current_time() + defp gen_ttl, do: @max_exp + defp gen_exp, do: current_time() + @max_exp + defp valid_expiration?(exp), do: exp >= current_time() && exp <= current_time() + @max_exp end diff --git a/apps/authenticator/lib/sign_in/commands/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/client_credentials.ex index ce6fec7..9414247 100644 --- a/apps/authenticator/lib/sign_in/commands/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/client_credentials.ex @@ -6,6 +6,9 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do secret (or client assertion) directly and we use it to authenticate before generating the access token. + When a public key is registered for the client application it should use the client assertion + because it's safer than pass the secret directly. + This flow is used in machine-to-machine authentication and when the application already has user's permission or it's not required to access an specific data. """ @@ -14,7 +17,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do alias Authenticator.Ports.ResourceManager, as: Port alias Authenticator.Sessions - alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Inputs.ClientCredentials, as: Input alias ResourceManager.Permissions.Scopes @@ -33,7 +36,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do with {:app, {:ok, app}} <- {:app, Port.get_identity(%{client_id: client_id})}, {:flow_enabled?, true} <- {:flow_enabled?, "client_credentials" in app.grant_flows}, {:app_active?, true} <- {:app_active?, app.status == "active"}, - {:secret_matches?, true} <- {:secret_matches?, app.secret == input.client_secret}, + {:secret_matches?, true} <- {:secret_matches?, secret_matches?(app, input)}, {:valid_protocol?, true} <- {:valid_protocol?, app.protocol == "openid-connect"}, {:ok, access_token, claims} <- generate_access_token(app, scope), {:ok, refresh_token, _} <- generate_refresh_token(app, claims), @@ -53,7 +56,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do {:error, :unauthenticated} {:secret_matches?, false} -> - Logger.info("Client application #{client_id} secret do not match any credential") + Logger.info("Client application #{client_id} credential didn't matches") {:error, :unauthenticated} {:confidential?, false} -> @@ -90,6 +93,24 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do def execute(_any), do: {:error, :invalid_params} + defp secret_matches?(%{client_id: id, public_key: public_key}, %{client_assertion: assertion}) + when is_binary(public_key) and is_binary(assertion) do + signer = Joken.Signer.create("RS256", %{"pem" => public_key}) + + assertion + |> ClientAssertion.verify_and_validate(signer, %{client_id: id}) + |> case do + {:ok, _claims} -> true + {:error, _reason} -> false + end + end + + defp secret_matches?(%{public_key: nil, secret: app_secret}, %{client_secret: input_secret}) + when is_binary(app_secret) and is_binary(input_secret), + do: app_secret == input_secret + + defp secret_matches?(_application, _input), do: false + defp build_scope(application, scopes) do app_scopes = Enum.map(application.scopes, & &1.name) diff --git a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex index 977df0a..5906f3e 100644 --- a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex @@ -14,21 +14,58 @@ defmodule Authenticator.SignIn.Inputs.ClientCredentials do } @possible_grant_type ~w(client_credentials) + @acceptable_assertion_type "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - @required [:client_id, :client_secret, :grant_type, :scope] + @required [:client_id, :grant_type, :scope] + @optional [:client_secret, :client_assertion, :client_assertion_type] embedded_schema do field :client_id, Ecto.UUID - field :client_secret, :string field :grant_type, :string field :scope, :string + + # Application credentials + field :client_secret, :string + field :client_assertion, :string + field :client_assertion_type, :string end @doc false def changeset(params) when is_map(params) do %__MODULE__{} - |> cast(params, @required) + |> cast(params, @required ++ @optional) |> validate_length(:client_secret, min: 1) |> validate_inclusion(:grant_type, @possible_grant_type) |> validate_required(@required) + |> validate_assertion_type() + |> validate_assertion() + end + + defp validate_assertion_type(%{changes: %{client_assertion_type: assertion_type}} = changeset) do + if assertion_type == @acceptable_assertion_type do + changeset + else + opts = [accepts: [@acceptable_assertion_type]] + add_error(changeset, :client_assertion_type, "invalid assertion type", opts) + end + end + + defp validate_assertion_type(changeset), do: changeset + + defp validate_assertion(%{changes: changes} = changeset) do + case changes do + %{client_assertion_type: _, client_assertion: _} -> + changeset + + %{client_secret: _} -> + changeset + + %{client_assertion_type: _} -> + add_error(changeset, :client_assertion, "can't be blank", validation: :required) + + _any -> + changeset + |> add_error(:client_assertion, "can't be blank", validation: :required) + |> add_error(:client_assertion_type, "can't be blank", validation: :required) + end end end diff --git a/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex index 5253d66..53b9fb1 100644 --- a/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex @@ -16,21 +16,27 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do } @possible_grant_type ~w(password) + @acceptable_assertion_type "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - @required [:username, :password, :client_id, :client_secret, :scope, :grant_type] + @required [:username, :password, :client_id, :scope, :grant_type] + @optional [:client_secret, :client_assertion, :client_assertion_type] embedded_schema do field :username, :string field :password, :string field :grant_type, :string field :scope, :string field :client_id, :string + + # Application credentials field :client_secret, :string + field :client_assertion, :string + field :client_assertion_type, :string end @doc false def changeset(params) when is_map(params) do %__MODULE__{} - |> cast(params, @required) + |> cast(params, @required ++ @optional) |> validate_length(:username, min: 1) |> validate_length(:password, min: 1) |> validate_inclusion(:grant_type, @possible_grant_type) @@ -38,5 +44,36 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do |> validate_length(:client_id, min: 1) |> validate_length(:client_secret, min: 1) |> validate_required(@required) + |> validate_assertion_type() + |> validate_assertion() + end + + defp validate_assertion_type(%{changes: %{client_assertion_type: assertion_type}} = changeset) do + if assertion_type == @acceptable_assertion_type do + changeset + else + opts = [accepts: [@acceptable_assertion_type]] + add_error(changeset, :client_assertion_type, "invalid assertion type", opts) + end + end + + defp validate_assertion_type(changeset), do: changeset + + defp validate_assertion(%{changes: changes} = changeset) do + case changes do + %{client_assertion_type: _, client_assertion: _} -> + changeset + + %{client_secret: _} -> + changeset + + %{client_assertion_type: _} -> + add_error(changeset, :client_assertion, "can't be blank", validation: :required) + + _any -> + changeset + |> add_error(:client_assertion, "can't be blank", validation: :required) + |> add_error(:client_assertion_type, "can't be blank", validation: :required) + end end end diff --git a/apps/authenticator/lib/sign_in/commands/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/resource_owner.ex index 7f91558..066422e 100644 --- a/apps/authenticator/lib/sign_in/commands/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/resource_owner.ex @@ -5,8 +5,11 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do With the resource owner password credentials grant type, the user provides their username and password directly and we uses it to authenticates then. - The Client application should pass their secret in order to be authorized to exchange - the credentials for an access_token. + The Client application should pass their secret (or client assertion) in order to be authorized + to exchange the credentials for an access_token. + + When a public key is registered for the client application it should use the client assertion + because it's safer than pass the secret directly. This grant type should only be enabled on the authorization server if other flows are not viable and should also only be used if the identity owner trusts in the application. @@ -17,7 +20,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do alias Authenticator.Crypto.Commands.{FakeVerifyHash, VerifyHash} alias Authenticator.Ports.ResourceManager, as: Port alias Authenticator.Sessions - alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Inputs.ResourceOwner, as: Input alias ResourceManager.Permissions.Scopes @@ -37,7 +40,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do with {:app, {:ok, app}} <- {:app, Port.get_identity(%{client_id: client_id})}, {:flow_enabled?, true} <- {:flow_enabled?, "resource_owner" in app.grant_flows}, {:app_active?, true} <- {:app_active?, app.status == "active"}, - {:secret_matches?, true} <- {:secret_matches?, app.secret == input.client_secret}, + {:secret_matches?, true} <- {:secret_matches?, secret_matches?(app, input)}, {:confidential?, true} <- {:confidential?, app.access_type == "confidential"}, {:valid_protocol?, true} <- {:valid_protocol?, app.protocol == "openid-connect"}, {:user, {:ok, user}} <- {:user, Port.get_identity(%{username: username})}, @@ -64,7 +67,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do {:error, :unauthenticated} {:secret_matches?, false} -> - Logger.info("Client application #{client_id} secret do not match any credential") + Logger.info("Client application #{client_id} credential didn't matches") FakeVerifyHash.execute(:argon2) {:error, :unauthenticated} @@ -118,6 +121,24 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do def execute(_any), do: {:error, :invalid_params} + defp secret_matches?(%{client_id: id, public_key: public_key}, %{client_assertion: assertion}) + when is_binary(public_key) and is_binary(assertion) do + signer = Joken.Signer.create("RS256", %{"pem" => public_key}) + + assertion + |> ClientAssertion.verify_and_validate(signer, %{client_id: id}) + |> case do + {:ok, _claims} -> true + {:error, _reason} -> false + end + end + + defp secret_matches?(%{public_key: nil, secret: secret}, %{client_secret: client_secret}) + when is_binary(secret) and is_binary(client_secret), + do: secret == client_secret + + defp secret_matches?(_application, _input), do: false + defp build_scope(user, application, scopes) do user_scopes = Enum.map(user.scopes, & &1.name) app_scopes = Enum.map(application.scopes, & &1.name) diff --git a/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs index 64c5721..84deca7 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs @@ -25,7 +25,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> assert app.client_id == client_id - {:ok, %{app | scopes: scopes}} + {:ok, %{app | public_key: nil, scopes: scopes}} end) assert {:ok, @@ -70,7 +70,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> assert app.client_id == client_id - {:ok, %{app | scopes: scopes}} + {:ok, %{app | public_key: nil, scopes: scopes}} end) assert {:ok, @@ -104,8 +104,9 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do assert {:error, %Ecto.Changeset{ errors: [ + client_assertion_type: {"can't be blank", [validation: :required]}, + client_assertion: {"can't be blank", [validation: :required]}, client_id: {"can't be blank", [validation: :required]}, - client_secret: {"can't be blank", [validation: :required]}, scope: {"can't be blank", [validation: :required]} ] }} = Command.execute(%{grant_type: "client_credentials"}) diff --git a/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs index 81dbb84..17cb371 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs @@ -30,7 +30,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> assert app.client_id == client_id - {:ok, %{app | scopes: scopes}} + {:ok, %{app | public_key: nil, scopes: scopes}} end) expect(ResourceManagerMock, :get_identity, fn %{username: username} -> @@ -85,7 +85,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> assert app.client_id == client_id - {:ok, %{app | scopes: scopes}} + {:ok, %{app | public_key: nil, scopes: scopes}} end) expect(ResourceManagerMock, :get_identity, fn %{username: username} -> @@ -124,10 +124,11 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do assert {:error, %Ecto.Changeset{ errors: [ + client_assertion_type: {"can't be blank", [validation: :required]}, + client_assertion: {"can't be blank", [validation: :required]}, username: {"can't be blank", [validation: :required]}, password: {"can't be blank", [validation: :required]}, client_id: {"can't be blank", [validation: :required]}, - client_secret: {"can't be blank", [validation: :required]}, scope: {"can't be blank", [validation: :required]} ] }} = Command.execute(%{grant_type: "password"}) @@ -245,7 +246,10 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do client_secret: app.secret } - expect(ResourceManagerMock, :get_identity, fn %{client_id: _} -> {:ok, app} end) + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: nil}} + end) expect(ResourceManagerMock, :get_identity, fn %{username: _} -> {:error, :not_found} end) @@ -264,7 +268,10 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do client_secret: app.secret } - expect(ResourceManagerMock, :get_identity, fn %{client_id: _} -> {:ok, app} end) + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: nil}} + end) expect(ResourceManagerMock, :get_identity, fn %{username: _} -> {:ok, RF.insert!(:user, status: "blocked")} @@ -285,7 +292,10 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do client_secret: app.secret } - expect(ResourceManagerMock, :get_identity, fn %{client_id: _} -> {:ok, app} end) + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: nil}} + end) expect(ResourceManagerMock, :get_identity, fn %{username: _} -> user = RF.insert!(:user, status: "blocked") diff --git a/apps/resource_manager/lib/identity/schemas/client_application.ex b/apps/resource_manager/lib/identity/schemas/client_application.ex index 9f5d5a1..1098ccd 100644 --- a/apps/resource_manager/lib/identity/schemas/client_application.ex +++ b/apps/resource_manager/lib/identity/schemas/client_application.ex @@ -64,8 +64,8 @@ defmodule ResourceManager.Identity.Schemas.ClientApplication do |> validate_inclusion(:status, @possible_statuses) |> validate_inclusion(:protocol, @possible_protocols) |> validate_inclusion(:access_type, @possible_access_types) + |> validate_subset(:grant_flows, @possible_grant_flows) |> unique_constraint(:name) - |> validate_grant_flows() |> generate_secret() end @@ -84,21 +84,10 @@ defmodule ResourceManager.Identity.Schemas.ClientApplication do |> validate_inclusion(:status, @possible_statuses) |> validate_inclusion(:protocol, @possible_protocols) |> validate_inclusion(:access_type, @possible_access_types) + |> validate_subset(:grant_flows, @possible_grant_flows) |> unique_constraint(:name) - |> validate_grant_flows() end - defp validate_grant_flows(%{valid?: true, changes: %{grant_flows: flows}} = changeset) do - if Enum.all?(flows, &(&1 in @possible_grant_flows)) do - changeset - else - opts = [validation: :subset, enum: @possible_grant_flows] - add_error(changeset, :grant_flows, "is invalid", opts) - end - end - - defp validate_grant_flows(changeset), do: changeset - @doc false def possible_statuses, do: @possible_statuses diff --git a/apps/rest_api/test/controllers/public/auth_test.exs b/apps/rest_api/test/controllers/public/auth_test.exs index 890be70..3364799 100644 --- a/apps/rest_api/test/controllers/public/auth_test.exs +++ b/apps/rest_api/test/controllers/public/auth_test.exs @@ -72,7 +72,8 @@ defmodule RestAPI.Controllers.Public.AuthTest do "response" => %{ "scope" => ["can't be blank"], "client_id" => ["can't be blank"], - "client_secret" => ["can't be blank"], + "client_assertion" => ["can't be blank"], + "client_assertion_type" => ["can't be blank"], "password" => ["can't be blank"], "username" => ["can't be blank"] } @@ -102,7 +103,8 @@ defmodule RestAPI.Controllers.Public.AuthTest do "response" => %{ "scope" => ["can't be blank"], "client_id" => ["can't be blank"], - "client_secret" => ["can't be blank"] + "client_assertion" => ["can't be blank"], + "client_assertion_type" => ["can't be blank"] } } = conn From f5e1331d822acc112f6fdc31e9c4c19bdc82ad1b Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 11:17:49 -0300 Subject: [PATCH 2/7] chore: add more docs to the authentication flows --- .../lib/sign_in/commands/client_credentials.ex | 7 +++++-- apps/authenticator/lib/sign_in/commands/resource_owner.ex | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/authenticator/lib/sign_in/commands/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/client_credentials.ex index 9414247..5183074 100644 --- a/apps/authenticator/lib/sign_in/commands/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/client_credentials.ex @@ -6,8 +6,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do secret (or client assertion) directly and we use it to authenticate before generating the access token. - When a public key is registered for the client application it should use the client assertion - because it's safer than pass the secret directly. + When a public key is registered for the client application this flow will require that + an assertion is passed instead of the raw secret to avoid sending it on requests. This flow is used in machine-to-machine authentication and when the application already has user's permission or it's not required to access an specific data. @@ -28,6 +28,9 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do The application has to be active, using openid-connect protocol in order to use this flow. + When the client application has a public_key saved on database we force the use of + client_assertions on input to avoid passing it's secret open on requests. + If we fail in some step before verifying user password we have to fake it's verification to avoid exposing identity existance and time attacks. """ diff --git a/apps/authenticator/lib/sign_in/commands/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/resource_owner.ex index 066422e..0b5eece 100644 --- a/apps/authenticator/lib/sign_in/commands/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/resource_owner.ex @@ -8,8 +8,8 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do The Client application should pass their secret (or client assertion) in order to be authorized to exchange the credentials for an access_token. - When a public key is registered for the client application it should use the client assertion - because it's safer than pass the secret directly. + When a public key is registered for the client application this flow will require that + an assertion is passed instead of the raw secret to avoid sending it on requests. This grant type should only be enabled on the authorization server if other flows are not viable and should also only be used if the identity owner trusts in the application. @@ -32,6 +32,9 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do The application has to be active, using openid-connect protocol and with access_type confidential in order to use this flow. + When the client application has a public_key saved on database we force the use of + client_assertions on input to avoid passing it's secret open on requests. + If we fail in some step before verifying user password we have to fake it's verification to avoid exposing identity existance and time attacks. """ From 9ff7eebba5cb6dc1601ba0878aa82ff23c5c1c3f Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 15:11:55 -0300 Subject: [PATCH 3/7] chore: add tests to client assertion flow --- .../lib/sessions/tokens/client_assertion.ex | 13 ++- .../sessions/tokens/hooks/validate_equalty.ex | 38 -------- .../sign_in/commands/client_credentials.ex | 7 +- .../commands/inputs/client_credentials.ex | 6 +- .../sign_in/commands/inputs/resource_owner.ex | 6 +- .../lib/sign_in/commands/resource_owner.ex | 13 ++- .../commands/client_credentials_test.exs | 76 ++++++++++++++- .../sign_in/commands/resource_owner_test.exs | 96 ++++++++++++++++++- apps/authenticator/test/support/factory.ex | 4 +- 9 files changed, 197 insertions(+), 62 deletions(-) delete mode 100644 apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex diff --git a/apps/authenticator/lib/sessions/tokens/client_assertion.ex b/apps/authenticator/lib/sessions/tokens/client_assertion.ex index 3100aff..21ebb05 100644 --- a/apps/authenticator/lib/sessions/tokens/client_assertion.ex +++ b/apps/authenticator/lib/sessions/tokens/client_assertion.ex @@ -5,23 +5,22 @@ defmodule Authenticator.Sessions.Tokens.ClientAssertion do use Joken.Config - add_hook Joken.Hooks.RequiredClaims, ~w(exp iat nbf iss aud jti sub typ) - add_hook Authenticator.Sessions.Tokens.Hooks.ValidateUUID, ~w(sub aud) - add_hook Authenticator.Sessions.Tokens.Hooks.ValidateEqualty, ~w(sub iss) + add_hook Joken.Hooks.RequiredClaims, ~w(exp iat nbf iss aud jti typ) + add_hook Authenticator.Sessions.Tokens.Hooks.ValidateUUID, ~w(iss) # Two hours in seconds @max_exp 60 * 60 * 2 - @default_issuer "WatcherEx" + @default_audience "WatcherEx" @default_type "Bearer" @impl true def token_config do - [iss: @default_issuer, skip: [:aud, :exp]] + [skip: [:iss, :aud, :exp]] |> default_claims() - |> add_claim("aud", & &1, &is_binary/1) + |> add_claim("iss", & &1, fn value, _, ctx -> value == ctx.client_id end) + |> add_claim("aud", & &1, fn value, _, _ -> value == @default_audience end) |> add_claim("exp", &gen_exp/0, fn exp, _, _ -> is_integer(exp) and valid_expiration?(exp) end) - |> add_claim("sub", & &1, fn value, _, ctx -> value == ctx.client_id end) |> add_claim("typ", nil, fn value, _, _ -> value == @default_type end) end diff --git a/apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex b/apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex deleted file mode 100644 index 9414273..0000000 --- a/apps/authenticator/lib/sessions/tokens/hooks/validate_equalty.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Authenticator.Sessions.Tokens.Hooks.ValidateEqualty do - @moduledoc """ - Helper to validate if a given claims is are iguals. - """ - - use Joken.Hooks - - @impl true - def after_validate([], _, _) do - raise "Missing iqualty claims options" - end - - def after_validate(claims_to_validate, _, _) when not is_list(claims_to_validate) do - raise "Options must be a list of claim keys" - end - - def after_validate(claims_to_validate, {:ok, claims} = result, input) do - keys = - claims_to_validate - |> Enum.map(&convert_keys/1) - |> MapSet.new() - - claims - |> Enum.filter(fn {key, _value} -> key in keys end) - |> Enum.uniq_by(fn {_key, value} -> value end) - |> case do - [] -> {:cont, result, input} - claims -> {:halt, {:error, [message: "Invalid token", invalid_equalty: get_keys(claims)]}} - end - end - - def after_validate(_, result, input), do: {:cont, result, input} - - defp convert_keys(key) when is_binary(key), do: key - defp convert_keys(key) when is_atom(key), do: Atom.to_string(key) - - defp get_keys(claims), do: Enum.map(claims, fn {key, _value} -> key end) -end diff --git a/apps/authenticator/lib/sign_in/commands/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/client_credentials.ex index 5183074..0ea558d 100644 --- a/apps/authenticator/lib/sign_in/commands/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/client_credentials.ex @@ -97,8 +97,8 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do def execute(_any), do: {:error, :invalid_params} defp secret_matches?(%{client_id: id, public_key: public_key}, %{client_assertion: assertion}) - when is_binary(public_key) and is_binary(assertion) do - signer = Joken.Signer.create("RS256", %{"pem" => public_key}) + when is_binary(assertion) do + signer = get_signer(public_key) assertion |> ClientAssertion.verify_and_validate(signer, %{client_id: id}) @@ -114,6 +114,9 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do defp secret_matches?(_application, _input), do: false + defp get_signer(%{value: pem, type: "rsa", format: "pem"}), + do: Joken.Signer.create("RS256", %{"pem" => pem}) + defp build_scope(application, scopes) do app_scopes = Enum.map(application.scopes, & &1.name) diff --git a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex index 5906f3e..cf6e866 100644 --- a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex @@ -14,7 +14,7 @@ defmodule Authenticator.SignIn.Inputs.ClientCredentials do } @possible_grant_type ~w(client_credentials) - @acceptable_assertion_type "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + @acceptable_assertion_types ~w(urn:ietf:params:oauth:client-assertion-type:jwt-bearer) @required [:client_id, :grant_type, :scope] @optional [:client_secret, :client_assertion, :client_assertion_type] @@ -41,10 +41,10 @@ defmodule Authenticator.SignIn.Inputs.ClientCredentials do end defp validate_assertion_type(%{changes: %{client_assertion_type: assertion_type}} = changeset) do - if assertion_type == @acceptable_assertion_type do + if assertion_type in @acceptable_assertion_types do changeset else - opts = [accepts: [@acceptable_assertion_type]] + opts = [enum: [@acceptable_assertion_types]] add_error(changeset, :client_assertion_type, "invalid assertion type", opts) end end diff --git a/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex index 53b9fb1..a54b01e 100644 --- a/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/inputs/resource_owner.ex @@ -16,7 +16,7 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do } @possible_grant_type ~w(password) - @acceptable_assertion_type "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + @acceptable_assertion_types ~w(urn:ietf:params:oauth:client-assertion-type:jwt-bearer) @required [:username, :password, :client_id, :scope, :grant_type] @optional [:client_secret, :client_assertion, :client_assertion_type] @@ -49,10 +49,10 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do end defp validate_assertion_type(%{changes: %{client_assertion_type: assertion_type}} = changeset) do - if assertion_type == @acceptable_assertion_type do + if assertion_type in @acceptable_assertion_types do changeset else - opts = [accepts: [@acceptable_assertion_type]] + opts = [enum: [@acceptable_assertion_types]] add_error(changeset, :client_assertion_type, "invalid assertion type", opts) end end diff --git a/apps/authenticator/lib/sign_in/commands/resource_owner.ex b/apps/authenticator/lib/sign_in/commands/resource_owner.ex index 0b5eece..5476413 100644 --- a/apps/authenticator/lib/sign_in/commands/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/resource_owner.ex @@ -125,8 +125,8 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do def execute(_any), do: {:error, :invalid_params} defp secret_matches?(%{client_id: id, public_key: public_key}, %{client_assertion: assertion}) - when is_binary(public_key) and is_binary(assertion) do - signer = Joken.Signer.create("RS256", %{"pem" => public_key}) + when is_binary(assertion) do + signer = get_signer_context(public_key) assertion |> ClientAssertion.verify_and_validate(signer, %{client_id: id}) @@ -136,12 +136,15 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do end end - defp secret_matches?(%{public_key: nil, secret: secret}, %{client_secret: client_secret}) - when is_binary(secret) and is_binary(client_secret), - do: secret == client_secret + defp secret_matches?(%{public_key: nil, secret: app_secret}, %{client_secret: input_secret}) + when is_binary(app_secret) and is_binary(input_secret), + do: app_secret == input_secret defp secret_matches?(_application, _input), do: false + defp get_signer_context(%{value: pem, type: "rsa", format: "pem"}), + do: Joken.Signer.create("RS256", %{"pem" => pem}) + defp build_scope(user, application, scopes) do user_scopes = Enum.map(user.scopes, & &1.name) app_scopes = Enum.map(application.scopes, & &1.name) diff --git a/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs index 84deca7..b07e97b 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs @@ -3,7 +3,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do alias Authenticator.Ports.ResourceManagerMock alias Authenticator.Sessions.Schemas.Session - alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Commands.ClientCredentials, as: Command describe "#{Command}.execute/1" do @@ -98,6 +98,80 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do assert %Session{jti: ^jti} = Repo.one(Session) end + test "succeeds using client_assertions and generates an access_token" do + scopes = RF.insert_list!(:scope, 3) + app = RF.insert!(:client_application, grant_flows: ["client_credentials"]) + public_key = RF.insert!(:public_key, client_application: app, value: get_priv_public_key()) + + signer = Joken.Signer.create("RS256", %{"pem" => get_priv_private_key()}) + + client_assertion = + ClientAssertion.generate_and_sign!( + %{"iss" => app.client_id, "aud" => "WatcherEx", "typ" => "Bearer"}, + signer + ) + + input = %{ + grant_type: "client_credentials", + scope: scopes |> Enum.map(& &1.name) |> Enum.join(" "), + client_id: app.client_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: client_assertion + } + + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: public_key, scopes: scopes}} + end) + + assert {:ok, + %{ + access_token: access_token, + refresh_token: nil, + expires_in: 7200, + token_type: _ + }} = Command.execute(input) + + assert is_binary(access_token) + end + + test "succeeds using client_assertions and generates a refresh_token" do + scopes = RF.insert_list!(:scope, 3) + app = RF.insert!(:client_application, grant_flows: ["client_credentials", "refresh_token"]) + public_key = RF.insert!(:public_key, client_application: app, value: get_priv_public_key()) + + signer = Joken.Signer.create("RS256", %{"pem" => get_priv_private_key()}) + + client_assertion = + ClientAssertion.generate_and_sign!( + %{"iss" => app.client_id, "aud" => "WatcherEx", "typ" => "Bearer"}, + signer + ) + + input = %{ + "grant_type" => "client_credentials", + "scope" => scopes |> Enum.map(& &1.name) |> Enum.join(" "), + "client_id" => app.client_id, + "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion" => client_assertion + } + + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: public_key, scopes: scopes}} + end) + + assert {:ok, + %{ + access_token: _, + refresh_token: refresh_token, + expires_in: 7200, + token_type: _ + }} = Command.execute(input) + + assert is_binary(refresh_token) + end + test "fails if params are invalid" do assert {:error, :invalid_params} == Command.execute(%{}) diff --git a/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs index 17cb371..5233cf5 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/resource_owner_test.exs @@ -3,7 +3,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do alias Authenticator.Ports.ResourceManagerMock alias Authenticator.Sessions.Schemas.Session - alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.Sessions.Tokens.{AccessToken, ClientAssertion, RefreshToken} alias Authenticator.SignIn.Commands.ResourceOwner, as: Command describe "#{Command}.execute/1" do @@ -118,6 +118,100 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do assert %Session{jti: ^jti} = Repo.one(Session) end + test "succeeds using client_assertions and generates an access_token" do + scopes = RF.insert_list!(:scope, 3) + user = RF.insert!(:user) + app = RF.insert!(:client_application) + public_key = RF.insert!(:public_key, client_application: app, value: get_priv_public_key()) + hash = RF.gen_hashed_password("MyPassw@rd234") + password = RF.insert!(:password, user: user, password_hash: hash) + + signer = Joken.Signer.create("RS256", %{"pem" => get_priv_private_key()}) + + client_assertion = + ClientAssertion.generate_and_sign!( + %{"iss" => app.client_id, "aud" => "WatcherEx", "typ" => "Bearer"}, + signer + ) + + input = %{ + username: user.username, + password: "MyPassw@rd234", + grant_type: "password", + scope: scopes |> Enum.map(& &1.name) |> Enum.join(" "), + client_id: app.client_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: client_assertion + } + + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: public_key, scopes: scopes}} + end) + + expect(ResourceManagerMock, :get_identity, fn %{username: username} -> + assert user.username == username + {:ok, %{user | password: password, scopes: scopes}} + end) + + assert {:ok, + %{ + access_token: access_token, + refresh_token: nil, + expires_in: 7200, + token_type: _ + }} = Command.execute(input) + + assert is_binary(access_token) + end + + test "succeeds using client_assertions and generates a refresh_token" do + scopes = RF.insert_list!(:scope, 3) + user = RF.insert!(:user) + app = RF.insert!(:client_application, grant_flows: ["resource_owner", "refresh_token"]) + public_key = RF.insert!(:public_key, client_application: app, value: get_priv_public_key()) + hash = RF.gen_hashed_password("MyPassw@rd234") + password = RF.insert!(:password, user: user, password_hash: hash) + + signer = Joken.Signer.create("RS256", %{"pem" => get_priv_private_key()}) + + client_assertion = + ClientAssertion.generate_and_sign!( + %{"iss" => app.client_id, "aud" => "WatcherEx", "typ" => "Bearer"}, + signer + ) + + input = %{ + "username" => user.username, + "password" => "MyPassw@rd234", + "grant_type" => "password", + "scope" => scopes |> Enum.map(& &1.name) |> Enum.join(" "), + "client_id" => app.client_id, + "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion" => client_assertion + } + + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | public_key: public_key, scopes: scopes}} + end) + + expect(ResourceManagerMock, :get_identity, fn %{username: username} -> + assert user.username == username + {:ok, %{user | password: password, scopes: scopes}} + end) + + assert {:ok, + %{ + access_token: _, + refresh_token: refresh_token, + expires_in: 7200, + token_type: _ + }} = Command.execute(input) + + assert is_binary(refresh_token) + end + test "fails if params are invalid" do assert {:error, :invalid_params} == Command.execute(%{}) diff --git a/apps/authenticator/test/support/factory.ex b/apps/authenticator/test/support/factory.ex index 7297641..c902f57 100644 --- a/apps/authenticator/test/support/factory.ex +++ b/apps/authenticator/test/support/factory.ex @@ -69,7 +69,7 @@ defmodule Authenticator.Factory do def get_priv_public_key do :authenticator |> :code.priv_dir() - |> Path.join("/keys/authenticator.pub") + |> Path.join("/keys/authenticator_key.pub") |> File.read!() end @@ -77,7 +77,7 @@ defmodule Authenticator.Factory do def get_priv_private_key do :authenticator |> :code.priv_dir() - |> Path.join("/keys/authenticator.pem") + |> Path.join("/keys/authenticator_key.pem") |> File.read!() end end From 687ee353cf7be002f720be83eb9dca1b79a91246 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 15:16:31 -0300 Subject: [PATCH 4/7] chore: rename session file --- apps/authenticator/lib/sessions/schemas/session.ex | 1 - .../test/authenticator/{sessions => }/sessions_test.exs | 0 2 files changed, 1 deletion(-) rename apps/authenticator/test/authenticator/{sessions => }/sessions_test.exs (100%) diff --git a/apps/authenticator/lib/sessions/schemas/session.ex b/apps/authenticator/lib/sessions/schemas/session.ex index be2c71d..f26e8c6 100644 --- a/apps/authenticator/lib/sessions/schemas/session.ex +++ b/apps/authenticator/lib/sessions/schemas/session.ex @@ -75,7 +75,6 @@ defmodule Authenticator.Sessions.Schemas.Session do ################# defp custom_query(query, {:ids, ids}), do: where(query, [c], c.id in ^ids) - defp custom_query(query, {:created_after, date}), do: where(query, [c], c.inserted_at > ^date) defp custom_query(query, {:created_before, date}), do: where(query, [c], c.inserted_at < ^date) defp custom_query(query, {:expires_after, date}), do: where(query, [c], c.expires_at > ^date) diff --git a/apps/authenticator/test/authenticator/sessions/sessions_test.exs b/apps/authenticator/test/authenticator/sessions_test.exs similarity index 100% rename from apps/authenticator/test/authenticator/sessions/sessions_test.exs rename to apps/authenticator/test/authenticator/sessions_test.exs From 61e8971c5be1d92104aed75de4b705aca148603a Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 15:26:38 -0300 Subject: [PATCH 5/7] fix: logout endpoint --- apps/rest_api/lib/controllers/public/auth.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/rest_api/lib/controllers/public/auth.ex b/apps/rest_api/lib/controllers/public/auth.ex index 47e12d8..3e09fa8 100644 --- a/apps/rest_api/lib/controllers/public/auth.ex +++ b/apps/rest_api/lib/controllers/public/auth.ex @@ -47,7 +47,7 @@ defmodule RestAPI.Controllers.Public.Auth do @doc "Logout the authenticated subject session." @spec sign_out(conn :: Plug.Conn.t(), params :: map()) :: Plug.Conn.t() def sign_out(%{private: %{session: session}} = conn, _params) do - session + session.jti |> Commands.sign_out_session() |> parse_sign_out_response(conn) end @@ -63,4 +63,5 @@ defmodule RestAPI.Controllers.Public.Auth do defp parse_sign_out_response({:ok, _any}, conn), do: send_resp(conn, :no_content, "") defp parse_sign_out_response({:error, :not_active}, conn), do: send_resp(conn, :forbidden, "") defp parse_sign_out_response({:error, :not_found}, conn), do: send_resp(conn, :not_found, "") + defp parse_sign_out_response({:error, _any} = error, _conn), do: error end From c180b89419ead9efdc0f7108c6257ca9c23b202b Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 15:35:10 -0300 Subject: [PATCH 6/7] chore: update docs --- apps/rest_api/README.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/rest_api/README.md b/apps/rest_api/README.md index 28f2a05..9b78350 100644 --- a/apps/rest_api/README.md +++ b/apps/rest_api/README.md @@ -9,7 +9,26 @@ Follow the `Running it locally` guide on project `README.md` in order to know ho ### Sign in by Resource Owner Flow -**Request**: +**Request (Client assertions)**: + +To generate the client assertions follow the exemple bellow (in the project iex): + +```elixir +signer = Joken.Signer.create("RS256", %{"pem" => "YOUR_PRIVATE_KEY_HERE"}) + +Authenticator.ClientAssertion.generate_and_sign!(%{"iss" => "YOUR_APP_CLIENT_ID", "aud" => "WatcherEx", "typ" => "Bearer"}, signer) +``` + +Then you can try out the request using: + +```sh +curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ + -H "Content-Type: application/json" \ + -d '{"username":"admin", "password":"admin", "grant_type":"password", "scope":"admin:read admin:write", "client_id": "2e455bb1-0604-4812-9756-36f7ab23b8d9", "client_assertion": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJXYXRjaGVyRXgiLCJleHAiOjE2MDEyMzgwOTMsImlhdCI6MTYwMTIzMDg5MywiaXNzIjoiMmU0NTViYjEtMDYwNC00ODEyLTk3NTYtMzZmN2FiMjNiOGQ5IiwianRpIjoiMm9zYmUwc3JrbTMyc2tvN2ZrMDAwMnAzIiwibmJmIjoxNjAxMjMwODkzLCJ0eXAiOiJCZWFyZXIifQ.SDUlLMO9kVLfxyRRJUCCVPpz2fcjUtxC1K3IJPa2NrBp7S-IUGuZx9965M09jFJOZrNzqEC9VRZb9KqlZS2T0bGUg3pk8R91oqOgnPOvXEQ8bjTKuvqIv7K7hKaAARxRTgBf-o87quUoVoZzepLzfmJdnDVXy0QoFIO7_SYe4zmq3mrrvHM5Kaypgf0JMiOZORr2kEnk0zEkPoIvqL8psTrLlaUHr-cn3l3F7eGARhHijOTXoFXTH4BFjJzsQJRKcz1cyzUQ64Y02JWeYsbfi1higF14lGnFTduuVwMpqa7Wu5xK9FhmR1mmlqqFgD6NVeiDxoDcAzhhDbQWdKuuAyqyr67uYfY5qeeudoKYyJcjvfE0c1iMLpEQAlZDK_HjoChBEORcTcvbsCD-75y2lJhqsrW0cTWoqq0YTXU3SHvdewEZto8AEaQMKHnGozQQEkeF7rOFOJF7P_LX2LV7JbtxIl8RZPvjNNF6F6VHy_DJTVoJJNbIRRm47v8fXBBej60_76XZmxG_FtgZBevVgINq_lnYf2nb_2RybxyzRxfC4pRvTh6Og8mZy5fcgYIa4Yq3eXdDVAVxrFJWrJqfjdPSuZbFDuq6VfiXOAd_bNqNHMLN_jiTtJlVJnS-gk9Ejot8X-kwG-UPDoAQZIfyBqMSXIqyL-qFfVR8dIX9Dps", "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}' +``` + + +**Request (Client secret)**: ```sh curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ @@ -51,7 +70,25 @@ curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ ### Sign in by Client Credentials Flow -**Request**: +**Request (Client Assertions)**: + +To generate the client assertions follow the exemple bellow (in the project iex): + +```elixir +signer = Joken.Signer.create("RS256", %{"pem" => "YOUR_PRIVATE_KEY_HERE"}) + +Authenticator.ClientAssertion.generate_and_sign!(%{"iss" => "YOUR_APP_CLIENT_ID", "aud" => "WatcherEx", "typ" => "Bearer"}, signer) +``` + +Then you can try out the request using: + +```sh +curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ + -H "Content-Type: application/json" \ + -d '{"grant_type":"client_credentials", "scope":"admin:read admin:write", "client_id": "2e455bb1-0604-4812-9756-36f7ab23b8d9", "client_assertion": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJXYXRjaGVyRXgiLCJleHAiOjE2MDEyMzgwOTMsImlhdCI6MTYwMTIzMDg5MywiaXNzIjoiMmU0NTViYjEtMDYwNC00ODEyLTk3NTYtMzZmN2FiMjNiOGQ5IiwianRpIjoiMm9zYmUwc3JrbTMyc2tvN2ZrMDAwMnAzIiwibmJmIjoxNjAxMjMwODkzLCJ0eXAiOiJCZWFyZXIifQ.SDUlLMO9kVLfxyRRJUCCVPpz2fcjUtxC1K3IJPa2NrBp7S-IUGuZx9965M09jFJOZrNzqEC9VRZb9KqlZS2T0bGUg3pk8R91oqOgnPOvXEQ8bjTKuvqIv7K7hKaAARxRTgBf-o87quUoVoZzepLzfmJdnDVXy0QoFIO7_SYe4zmq3mrrvHM5Kaypgf0JMiOZORr2kEnk0zEkPoIvqL8psTrLlaUHr-cn3l3F7eGARhHijOTXoFXTH4BFjJzsQJRKcz1cyzUQ64Y02JWeYsbfi1higF14lGnFTduuVwMpqa7Wu5xK9FhmR1mmlqqFgD6NVeiDxoDcAzhhDbQWdKuuAyqyr67uYfY5qeeudoKYyJcjvfE0c1iMLpEQAlZDK_HjoChBEORcTcvbsCD-75y2lJhqsrW0cTWoqq0YTXU3SHvdewEZto8AEaQMKHnGozQQEkeF7rOFOJF7P_LX2LV7JbtxIl8RZPvjNNF6F6VHy_DJTVoJJNbIRRm47v8fXBBej60_76XZmxG_FtgZBevVgINq_lnYf2nb_2RybxyzRxfC4pRvTh6Og8mZy5fcgYIa4Yq3eXdDVAVxrFJWrJqfjdPSuZbFDuq6VfiXOAd_bNqNHMLN_jiTtJlVJnS-gk9Ejot8X-kwG-UPDoAQZIfyBqMSXIqyL-qFfVR8dIX9Dps", "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}' +``` + +**Request (client secret)**: ```sh curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ From 5b3ec6278440d10c43ecb2b1cc2cb44ea64acaf8 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Sun, 27 Sep 2020 17:37:31 -0300 Subject: [PATCH 7/7] fix: controller tests --- apps/rest_api/test/controllers/public/auth_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/rest_api/test/controllers/public/auth_test.exs b/apps/rest_api/test/controllers/public/auth_test.exs index 3364799..63c8562 100644 --- a/apps/rest_api/test/controllers/public/auth_test.exs +++ b/apps/rest_api/test/controllers/public/auth_test.exs @@ -129,7 +129,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, success_session(claims)} end) - expect(AuthenticatorMock, :sign_out_session, fn %{jti: jti} -> + expect(AuthenticatorMock, :sign_out_session, fn jti -> assert claims["jti"] == jti {:ok, %{}} end) @@ -151,7 +151,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, success_session(claims)} end) - expect(AuthenticatorMock, :sign_out_session, fn %{jti: jti} -> + expect(AuthenticatorMock, :sign_out_session, fn jti -> assert claims["jti"] == jti {:error, :not_active} end) @@ -173,7 +173,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, success_session(claims)} end) - expect(AuthenticatorMock, :sign_out_session, fn %{jti: jti} -> + expect(AuthenticatorMock, :sign_out_session, fn jti -> assert claims["jti"] == jti {:error, :not_found} end)