Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/authenticator/lib/authenticator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/authenticator/lib/sessions/schemas/session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
150 changes: 150 additions & 0 deletions apps/authenticator/lib/sign_in/commands/client_credentials.ex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions apps/authenticator/lib/sign_in/commands/resource_owner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading