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/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/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..21ebb05 --- /dev/null +++ b/apps/authenticator/lib/sessions/tokens/client_assertion.ex @@ -0,0 +1,29 @@ +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 typ) + add_hook Authenticator.Sessions.Tokens.Hooks.ValidateUUID, ~w(iss) + + # Two hours in seconds + @max_exp 60 * 60 * 2 + + @default_audience "WatcherEx" + @default_type "Bearer" + + @impl true + def token_config do + [skip: [:iss, :aud, :exp]] + |> default_claims() + |> 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("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/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..0ea558d 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 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. """ @@ -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 @@ -25,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. """ @@ -33,7 +39,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 +59,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 +96,27 @@ 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(assertion) do + signer = get_signer(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 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 977df0a..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,21 +14,58 @@ defmodule Authenticator.SignIn.Inputs.ClientCredentials do } @possible_grant_type ~w(client_credentials) + @acceptable_assertion_types ~w(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 in @acceptable_assertion_types do + changeset + else + opts = [enum: [@acceptable_assertion_types]] + 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..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,21 +16,27 @@ defmodule Authenticator.SignIn.Inputs.ResourceOwner do } @possible_grant_type ~w(password) + @acceptable_assertion_types ~w(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 in @acceptable_assertion_types do + changeset + else + opts = [enum: [@acceptable_assertion_types]] + 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..5476413 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 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. @@ -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 @@ -29,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. """ @@ -37,7 +43,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 +70,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 +124,27 @@ 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(assertion) do + signer = get_signer_context(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 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/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 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..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 @@ -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, @@ -98,14 +98,89 @@ 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(%{}) 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..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 @@ -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} -> @@ -118,16 +118,111 @@ 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(%{}) 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 +340,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 +362,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 +386,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/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 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/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 \ 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 diff --git a/apps/rest_api/test/controllers/public/auth_test.exs b/apps/rest_api/test/controllers/public/auth_test.exs index 890be70..63c8562 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 @@ -127,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) @@ -149,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) @@ -171,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)