diff --git a/apps/authenticator/lib/authenticator.ex b/apps/authenticator/lib/authenticator.ex index de41a78..5ef6ecf 100644 --- a/apps/authenticator/lib/authenticator.ex +++ b/apps/authenticator/lib/authenticator.ex @@ -6,7 +6,7 @@ defmodule Authenticator do alias Authenticator.Crypto.Commands.{FakeVerifyHash, GenerateHash, VerifyHash} alias Authenticator.Sessions.Commands.GetSession alias Authenticator.Sessions.Tokens.AccessToken - alias Authenticator.SignIn.Commands.{RefreshToken, ResourceOwner} + alias Authenticator.SignIn.Commands.{ClientCredentials, RefreshToken, ResourceOwner} alias Authenticator.SignOut.Commands.{SignOutAllSessions, SignOutSession} @doc "Delegates to #{ResourceOwner}.execute/1" @@ -15,6 +15,9 @@ defmodule Authenticator do @doc "Delegates to #{RefreshToken}.execute/1" defdelegate sign_in_refresh_token(input), to: RefreshToken, as: :execute + @doc "Delegates to #{ClientCredentials}.execute/1" + defdelegate sign_in_client_credentials(input), to: ClientCredentials, as: :execute + @doc "Delegates to #{GetSession}.execute/1" defdelegate get_session(input), to: GetSession, as: :execute diff --git a/apps/authenticator/lib/sessions/schemas/session.ex b/apps/authenticator/lib/sessions/schemas/session.ex index f42d3ca..be2c71d 100644 --- a/apps/authenticator/lib/sessions/schemas/session.ex +++ b/apps/authenticator/lib/sessions/schemas/session.ex @@ -28,7 +28,7 @@ defmodule Authenticator.Sessions.Schemas.Session do @possible_statuses ~w(active expired invalidated refreshed) @possible_subject_types ~w(user application) - @possible_grant_flows ~w(resource_owner refresh_token) + @possible_grant_flows ~w(client_credentials resource_owner refresh_token) @required_fields [:jti, :subject_id, :subject_type, :claims, :expires_at, :grant_flow] @optional_fields [:status] diff --git a/apps/authenticator/lib/sign_in/commands/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/client_credentials.ex new file mode 100644 index 0000000..ffd11f0 --- /dev/null +++ b/apps/authenticator/lib/sign_in/commands/client_credentials.ex @@ -0,0 +1,150 @@ +defmodule Authenticator.SignIn.Commands.ClientCredentials do + @moduledoc """ + Authenticates the client application identity using the Client Credentials Flow. + + With the client credentials grant type, the client application provides their + secret (or client assertion) directly and we use it to authenticate before generating + the access token. + + 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. + """ + + require Logger + + alias Authenticator.Ports.ResourceManager, as: Port + alias Authenticator.Sessions + alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.SignIn.Inputs.ClientCredentials, as: Input + alias ResourceManager.Permissions.Scopes + + @behaviour Authenticator.SignIn.Commands.Behaviour + + @doc """ + Sign in an client application identity by Client Credentials flow. + + The application has to be active, using openid-connect protocol in order to use this flow. + + 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. + """ + @impl true + def execute(%Input{client_id: client_id, scope: scope} = input) 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}, + {: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), + {:ok, _session} <- generate_session(claims) do + {:ok, parse_response(access_token, refresh_token, claims)} + else + {:app, {:error, :not_found}} -> + Logger.info("Client application #{client_id} not found") + {:error, :unauthenticated} + + {:flow_enabled?, false} -> + Logger.info("Client application #{client_id} client_credentials flow not enabled") + {:error, :unauthenticated} + + {:app_active?, false} -> + Logger.info("Client application #{client_id} is not active") + {:error, :unauthenticated} + + {:secret_matches?, false} -> + Logger.info("Client application #{client_id} secret do not match any credential") + {:error, :unauthenticated} + + {:confidential?, false} -> + Logger.info("Client application #{client_id} is not confidential") + {:error, :unauthenticated} + + {:valid_protocol?, false} -> + Logger.info("Client application #{client_id} protocol is not openid-connect") + {:error, :unauthenticated} + + error -> + Logger.error("Failed to run command becuase of unknow error", error: inspect(error)) + error + end + end + + def execute(%{"grant_type" => "client_credentials"} = params) do + params + |> Input.cast_and_apply() + |> case do + {:ok, %Input{} = input} -> execute(input) + error -> error + end + end + + def execute(%{grant_type: "client_credentials"} = params) do + params + |> Input.cast_and_apply() + |> case do + {:ok, %Input{} = input} -> execute(input) + error -> error + end + end + + def execute(_any), do: {:error, :invalid_params} + + defp build_scope(application, scopes) do + app_scopes = Enum.map(application.scopes, & &1.name) + + scopes + |> Scopes.convert_to_list() + |> Enum.filter(&(&1 in app_scopes)) + |> Scopes.convert_to_string() + |> case do + "" -> nil + scope -> scope + end + end + + defp generate_access_token(application, scope) do + AccessToken.generate_and_sign(%{ + "aud" => application.client_id, + "azp" => application.name, + "sub" => application.id, + "typ" => "Bearer", + "identity" => "user", + "scope" => build_scope(application, scope) + }) + end + + defp generate_refresh_token(application, %{"aud" => aud, "azp" => azp, "jti" => jti}) do + if "refresh_token" in application.grant_flows do + RefreshToken.generate_and_sign(%{ + "aud" => aud, + "azp" => azp, + "ati" => jti, + "typ" => "Bearer" + }) + else + Logger.info("Refresh token not enabled for application #{application.client_id}") + {:ok, nil, nil} + end + end + + defp generate_session(%{"jti" => jti, "sub" => sub, "exp" => exp} = claims) do + Sessions.create(%{ + jti: jti, + subject_id: sub, + subject_type: "application", + claims: claims, + expires_at: Sessions.convert_expiration(exp), + grant_flow: "client_credentials" + }) + end + + defp parse_response(access_token, refresh_token, %{"ttl" => ttl, "typ" => typ}) do + %{ + access_token: access_token, + refresh_token: refresh_token, + expires_in: ttl, + token_type: typ + } + end +end diff --git a/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex new file mode 100644 index 0000000..977df0a --- /dev/null +++ b/apps/authenticator/lib/sign_in/commands/inputs/client_credentials.ex @@ -0,0 +1,34 @@ +defmodule Authenticator.SignIn.Inputs.ClientCredentials do + @moduledoc """ + Input schema to be used in Client Credentials flow. + """ + + use Authenticator.Input + + @typedoc "Client credential flow input fields" + @type t :: %__MODULE__{ + client_id: String.t(), + client_secret: String.t(), + grant_type: String.t(), + scope: String.t() + } + + @possible_grant_type ~w(client_credentials) + + @required [:client_id, :client_secret, :grant_type, :scope] + embedded_schema do + field :client_id, Ecto.UUID + field :client_secret, :string + field :grant_type, :string + field :scope, :string + end + + @doc false + def changeset(params) when is_map(params) do + %__MODULE__{} + |> cast(params, @required) + |> validate_length(:client_secret, min: 1) + |> validate_inclusion(:grant_type, @possible_grant_type) + |> validate_required(@required) + 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 779e5e5..7f91558 100644 --- a/apps/authenticator/lib/sign_in/commands/resource_owner.ex +++ b/apps/authenticator/lib/sign_in/commands/resource_owner.ex @@ -3,8 +3,7 @@ defmodule Authenticator.SignIn.Commands.ResourceOwner do Authenticates the user identity using the Resource Owner Flow. With the resource owner password credentials grant type, the user provides their - service credentials (username and password) directly and we uses it to authenticates - then. + 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. 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 new file mode 100644 index 0000000..dff9613 --- /dev/null +++ b/apps/authenticator/test/authenticator/sign_in/commands/client_credentials_test.exs @@ -0,0 +1,194 @@ +defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do + use Authenticator.DataCase, async: true + + alias Authenticator.Ports.ResourceManagerMock + alias Authenticator.Sessions.Schemas.Session + alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} + alias Authenticator.SignIn.Commands.ClientCredentials, as: Command + + describe "#{Command}.execute/1" do + test "succeeds and generates an access_token" do + scopes = RF.insert_list!(:scope, 3) + app = RF.insert!(:client_application, grant_flows: ["client_credentials"]) + + subject_id = app.id + client_id = app.client_id + client_name = app.name + scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ") + + input = %{ + grant_type: "client_credentials", + scope: scope, + client_id: client_id, + client_secret: app.secret + } + + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | scopes: scopes}} + end) + + assert {:ok, + %{ + access_token: access_token, + refresh_token: nil, + expires_in: 7200, + token_type: typ + }} = Command.execute(input) + + assert {:ok, + %{ + "aud" => ^client_id, + "azp" => ^client_name, + "exp" => _, + "iat" => _, + "iss" => "WatcherEx", + "jti" => jti, + "nbf" => _, + "scope" => ^scope, + "identity" => "user", + "sub" => ^subject_id, + "typ" => ^typ + }} = AccessToken.verify_and_validate(access_token) + + assert %Session{jti: ^jti} = Repo.one(Session) + end + + test "succeeds and generates a refresh_token" do + scopes = RF.insert_list!(:scope, 3) + app = RF.insert!(:client_application, grant_flows: ["client_credentials", "refresh_token"]) + + client_id = app.client_id + scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ") + + input = %{ + "grant_type" => "client_credentials", + "scope" => scope, + "client_id" => client_id, + "client_secret" => app.secret + } + + expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> + assert app.client_id == client_id + {:ok, %{app | scopes: scopes}} + end) + + assert {:ok, + %{ + access_token: access_token, + refresh_token: refresh_token, + expires_in: 7200, + token_type: typ + }} = Command.execute(input) + + assert {:ok, %{"jti" => jti}} = RefreshToken.verify_and_validate(access_token) + + assert {:ok, + %{ + "aud" => ^client_id, + "ati" => ^jti, + "exp" => _, + "iat" => _, + "iss" => "WatcherEx", + "jti" => _, + "nbf" => _, + "typ" => ^typ + }} = RefreshToken.verify_and_validate(refresh_token) + + assert %Session{jti: ^jti} = Repo.one(Session) + end + + test "fails if params are invalid" do + assert {:error, :invalid_params} == Command.execute(%{}) + + assert {:error, + %Ecto.Changeset{ + errors: [ + 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"}) + end + + test "fails if client application do not exist" do + input = %{ + grant_type: "client_credentials", + scope: "admin:read", + client_id: Ecto.UUID.generate(), + client_secret: "my-secret" + } + + expect(ResourceManagerMock, :get_identity, fn _ -> {:error, :not_found} end) + + assert {:error, :unauthenticated} == Command.execute(input) + end + + test "fails if client application flow is not enabled" do + input = %{ + grant_type: "client_credentials", + scope: "admin:read", + client_id: Ecto.UUID.generate(), + client_secret: "my-secret" + } + + expect(ResourceManagerMock, :get_identity, fn _ -> + {:ok, RF.insert!(:client_application, grant_flows: [])} + end) + + assert {:error, :unauthenticated} == Command.execute(input) + end + + test "fails if client application is inactive" do + app = + RF.insert!( + :client_application, + grant_flows: ["client_credentials"], + status: "blocked" + ) + + input = %{ + grant_type: "client_credentials", + scope: "admin:read", + client_id: Ecto.UUID.generate(), + client_secret: "my-secret" + } + + expect(ResourceManagerMock, :get_identity, fn _ -> + {:ok, app} + end) + + assert {:error, :unauthenticated} == Command.execute(input) + end + + test "fails if client application secret do not match credential" do + input = %{ + grant_type: "client_credentials", + scope: "admin:read", + client_id: Ecto.UUID.generate(), + client_secret: Ecto.UUID.generate() + } + + expect(ResourceManagerMock, :get_identity, fn _ -> + {:ok, RF.insert!(:client_application, secret: "another-secret")} + end) + + assert {:error, :unauthenticated} == Command.execute(input) + end + + test "fails if client application protocol is not openid-connect" do + input = %{ + grant_type: "client_credentials", + scope: "admin:read", + client_id: Ecto.UUID.generate(), + client_secret: "my-secret" + } + + expect(ResourceManagerMock, :get_identity, fn _ -> + {:ok, RF.insert!(:client_application, protocol: "saml")} + end) + + assert {:error, :unauthenticated} == Command.execute(input) + end + end +end diff --git a/apps/authenticator/test/authenticator/sign_in/commands/refresh_token_test.exs b/apps/authenticator/test/authenticator/sign_in/commands/refresh_token_test.exs index 8bb91be..5e59394 100644 --- a/apps/authenticator/test/authenticator/sign_in/commands/refresh_token_test.exs +++ b/apps/authenticator/test/authenticator/sign_in/commands/refresh_token_test.exs @@ -4,9 +4,9 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do alias Authenticator.Ports.ResourceManagerMock alias Authenticator.Sessions.Schemas.Session alias Authenticator.Sessions.Tokens.{AccessToken, RefreshToken} - alias Authenticator.SignIn.Commands.RefreshToken, as: Commands + alias Authenticator.SignIn.Commands.RefreshToken, as: Command - describe "#{Commands}.execute/1" do + describe "#{Command}.execute/1" do test "succeeds and generates both tokens" do scopes = RF.insert_list!(:scope, 3) user = RF.insert!(:user) @@ -55,7 +55,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do refresh_token: refresh_token, expires_in: 7200, token_type: typ - }} = Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + }} = Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) assert %Session{jti: ^jti, status: "refreshed"} = Repo.get_by(Session, jti: jti) @@ -143,7 +143,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do refresh_token: refresh_token, expires_in: 7200, token_type: typ - }} = Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + }} = Command.execute(%{"refresh_token" => token, "grant_type" => "refresh_token"}) assert %Session{jti: ^jti, status: "refreshed"} = Repo.get_by(Session, jti: jti) @@ -178,10 +178,12 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do end test "fails if params are invalid" do + assert {:error, :invalid_params} == Command.execute(%{}) + assert {:error, %Ecto.Changeset{ errors: [refresh_token: {"can't be blank", [validation: :required]}] - }} = Commands.execute(%{grant_type: "refresh_token"}) + }} = Command.execute(%{grant_type: "refresh_token"}) end test "fails if session does not exist" do @@ -208,7 +210,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do {:ok, token, _} = build_refresh_token(refresh_token_claims) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if session was invalidated" do @@ -244,7 +246,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do {:ok, token, _} = build_refresh_token(refresh_token_claims) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if session already refreshed" do @@ -280,7 +282,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do {:ok, token, _} = build_refresh_token(refresh_token_claims) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if client application flow is not enabled" do @@ -313,7 +315,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do expect(ResourceManagerMock, :get_identity, fn _ -> {:ok, app} end) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if client application protocol is not openid-connect" do @@ -352,7 +354,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do expect(ResourceManagerMock, :get_identity, fn _ -> {:ok, app} end) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if client application is inactive" do @@ -391,7 +393,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do expect(ResourceManagerMock, :get_identity, fn _ -> {:ok, app} end) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if subject does not exist" do @@ -431,7 +433,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do expect(ResourceManagerMock, :get_identity, fn _ -> {:error, :not_found} end) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end test "fails if subject is inactive" do @@ -470,7 +472,7 @@ defmodule Authenticator.SignIn.Commands.RefreshTokenTest do expect(ResourceManagerMock, :get_identity, fn _ -> {:ok, user} end) assert {:error, :unauthenticated} == - Commands.execute(%{refresh_token: token, grant_type: "refresh_token"}) + Command.execute(%{refresh_token: token, grant_type: "refresh_token"}) end end end 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 29e975c..81dbb84 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 @@ -75,12 +75,12 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do scope = scopes |> Enum.map(& &1.name) |> Enum.join(" ") input = %{ - username: user.username, - password: "MyPassw@rd234", - grant_type: "password", - scope: scope, - client_id: client_id, - client_secret: app.secret + "username" => user.username, + "password" => "MyPassw@rd234", + "grant_type" => "password", + "scope" => scope, + "client_id" => client_id, + "client_secret" => app.secret } expect(ResourceManagerMock, :get_identity, fn %{client_id: client_id} -> @@ -119,6 +119,8 @@ defmodule Authenticator.SignIn.Commands.ResourceOwnerTest do end test "fails if params are invalid" do + assert {:error, :invalid_params} == Command.execute(%{}) + assert {:error, %Ecto.Changeset{ errors: [