diff --git a/README.md b/README.md index f000d78..a3fa9e2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # WatcherEx ![Build](https://github.com/lcpojr/watcher_ex/workflows/CI/badge.svg) [![Coverage](https://coveralls.io/repos/github/lcpojr/watcher_ex/badge.svg)](https://coveralls.io/github/lcpojr/watcher_ex) +**This is a work in progress and every contribution is welcome :)** + **TODO: Add description** ## Requirements @@ -26,85 +28,24 @@ In order to prepare the application run: Now that you have everything configured you can just call `mix phx.server` to get all applications running. The service will be available at `localhost:4000`. -### Making requests +### Seeding the database You can run the seeds in order to create an user and application for tests by using `mix seed`. -In order to gen you user and application data run on project iex (`iex -S mix phx.server`); +To get the user and application data check out the database on `localhost:8181` or run the project with using (`iex -S mix phx.server`) and execute the commands bellow. ```elixir +# Getting all user identities +# The user password will be `admin` ResourceManager.Repo.all(ResourceManager.Identity.Schemas.User) |> ResourceManager.Repo.preload([:scopes]) -ResourceManager.Repo.all(ResourceManager.Identity.Schemas.ClientApplication) |> ResourceManager.Repo.preload([:scopes]) -``` - -**Sign in by Resource Owner Flow** - -Request: - -```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_secret": "$2b$12$BSrTLJnb0Vfuk1iiSzw3MehAvgztbMYpnhneVLQhkoZbxAXBGUCFe"}' -``` - -Response (200): - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDc5NzU2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJuYmYiOjE2MDA3OTAzNjcsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.LWniDC38j2kW8ER8kgDnVVJO0eOXWGNq0KqXooMl-5s", - "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjM2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210OG5vbjRkZHQ5YzgwMDAxcTEiLCJuYmYiOjE2MDA3OTAzNjcsInR5cCI6IkJlYXJlciJ9.U010q6KUB04K8rIU9rVnW_AOI1q5XSXSGIYdL1moaOA", - "expires_in": 7200000, - "token_type": "Bearer" -} -``` - -**Sign in by Refresh Token Flow** - -Request: - -```sh -curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ - -H "Content-Type: application/json" \ - -d '{"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjM2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210OG5vbjRkZHQ5YzgwMDAxcTEiLCJuYmYiOjE2MDA3OTAzNjcsInR5cCI6IkJlYXJlciJ9.U010q6KUB04K8rIU9rVnW_AOI1q5XSXSGIYdL1moaOA", "grant_type": "refresh_token"}' -``` - -Response (200): - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDc5NzgwOSwiaWF0IjoxNjAwNzkwNjA5LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpZDUwYXRja3JiMzMyZWswMDAxczEiLCJuYmYiOjE2MDA3OTA2MDksInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.GnuyK5JTgg0PCeUtT79s847a3qPWgBjE8UqYoK1DG8o", - "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpZDUwYXRja3JiMzMyZWswMDAxczEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjYwOSwiaWF0IjoxNjAwNzkwNjA5LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpZDUwYXRpOHJ2MzMyZWswMDAxdDEiLCJuYmYiOjE2MDA3OTA2MDksInR5cCI6IkJlYXJlciJ9.HIL0AMMKJdYUibSXyYXfYGBEMIZsuudvFUHcF-VjXRg", - "expires_in": 7200000, - "token_type": "Bearer" -} -``` - -**Sign out all active sessions** - -Request: - -```sh -curl -X POST api/v1/auth/protocol/openid-connect/logout-all-sessions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDgyMzMxNiwiaWF0IjoxNjAwODE2MTE2LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JqcmhuMHNxdDlncjk3ZXMwMDAzMDMiLCJuYmYiOjE2MDA4MTYxMTYsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.NxFH6MIOFGc54UR9EVLPFB0m-6b-YMyXhZrOuGxErdw" -``` - -Response (204) - -`No content` - -**Sign out the given session** -Request: - -```sh -curl -X POST api/v1/auth/protocol/openid-connect/logout \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDgyMzMxNiwiaWF0IjoxNjAwODE2MTE2LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JqcmhuMHNxdDlncjk3ZXMwMDAzMDMiLCJuYmYiOjE2MDA4MTYxMTYsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.NxFH6MIOFGc54UR9EVLPFB0m-6b-YMyXhZrOuGxErdw" +# Getting all client application identities +# Check out for the client secret +ResourceManager.Repo.all(ResourceManager.Identity.Schemas.ClientApplication) |> ResourceManager.Repo.preload([:scopes]) ``` -Response (204) +### Making requests -`No content` +Check out the rest api guide on the specific application `README.md`. ## Testing diff --git a/apps/authenticator/lib/sign_in/commands/client_credentials.ex b/apps/authenticator/lib/sign_in/commands/client_credentials.ex index ffd11f0..ce6fec7 100644 --- a/apps/authenticator/lib/sign_in/commands/client_credentials.ex +++ b/apps/authenticator/lib/sign_in/commands/client_credentials.ex @@ -109,7 +109,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentials do "azp" => application.name, "sub" => application.id, "typ" => "Bearer", - "identity" => "user", + "identity" => "application", "scope" => build_scope(application, scope) }) end diff --git a/apps/authenticator/lib/sign_out/commands/sign_out_all_sessions.ex b/apps/authenticator/lib/sign_out/commands/sign_out_all_sessions.ex index 773e617..a2e0430 100644 --- a/apps/authenticator/lib/sign_out/commands/sign_out_all_sessions.ex +++ b/apps/authenticator/lib/sign_out/commands/sign_out_all_sessions.ex @@ -29,7 +29,7 @@ defmodule Authenticator.SignOut.Commands.SignOutAllSessions do |> Repo.transaction() |> case do {:ok, %{invalidate: 0}} -> - Logger.info("Succeeds on command but any active session was found") + Logger.info("Succeeds on command but none session was found") {:error, :not_active} {:ok, %{invalidate: count}} -> 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 dff9613..64c5721 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 @@ -46,7 +46,7 @@ defmodule Authenticator.SignIn.Commands.ClientCredentialsTest do "jti" => jti, "nbf" => _, "scope" => ^scope, - "identity" => "user", + "identity" => "application", "sub" => ^subject_id, "typ" => ^typ }} = AccessToken.verify_and_validate(access_token) diff --git a/apps/resource_manager/lib/identity/schemas/client_application.ex b/apps/resource_manager/lib/identity/schemas/client_application.ex index 445a939..9f5d5a1 100644 --- a/apps/resource_manager/lib/identity/schemas/client_application.ex +++ b/apps/resource_manager/lib/identity/schemas/client_application.ex @@ -63,9 +63,9 @@ defmodule ResourceManager.Identity.Schemas.ClientApplication do |> validate_length(:name, min: 1, max: 150) |> validate_inclusion(:status, @possible_statuses) |> validate_inclusion(:protocol, @possible_protocols) - |> validate_inclusion(:grant_flows, @possible_grant_flows) |> validate_inclusion(:access_type, @possible_access_types) |> unique_constraint(:name) + |> validate_grant_flows() |> generate_secret() end @@ -83,11 +83,22 @@ defmodule ResourceManager.Identity.Schemas.ClientApplication do |> validate_length(:name, min: 1, max: 150) |> validate_inclusion(:status, @possible_statuses) |> validate_inclusion(:protocol, @possible_protocols) - |> validate_inclusion(:grant_flows, @possible_grant_flows) |> validate_inclusion(:access_type, @possible_access_types) |> 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/resource_manager/priv/repo/seeds.exs b/apps/resource_manager/priv/repo/seeds.exs index a404055..032984d 100644 --- a/apps/resource_manager/priv/repo/seeds.exs +++ b/apps/resource_manager/priv/repo/seeds.exs @@ -23,7 +23,7 @@ Repo.transaction(fn -> name: "admin", description: "Admin test application", status: "active", - grant_flows: ["resource_owner", "refresh_token"], + grant_flows: ["resource_owner", "refresh_token", "client_credentials"], secret: Bcrypt.hash_pwd_salt("my-secret") }) diff --git a/apps/rest_api/README.md b/apps/rest_api/README.md index 316927e..28f2a05 100644 --- a/apps/rest_api/README.md +++ b/apps/rest_api/README.md @@ -1,3 +1,99 @@ # RestAPI -**TODO: Add description** +This application handles all requests on the restfull api. +It exposes public and admin endpoints. + +## Testing it locally + +Follow the `Running it locally` guide on project `README.md` in order to know how to install all dependencies and get the server ready. + +### Sign in by Resource Owner Flow + +**Request**: + +```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_secret": "$2b$12$BSrTLJnb0Vfuk1iiSzw3MehAvgztbMYpnhneVLQhkoZbxAXBGUCFe"}' +``` + +**Response (200)**: + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDc5NzU2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJuYmYiOjE2MDA3OTAzNjcsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.LWniDC38j2kW8ER8kgDnVVJO0eOXWGNq0KqXooMl-5s", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjM2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210OG5vbjRkZHQ5YzgwMDAxcTEiLCJuYmYiOjE2MDA3OTAzNjcsInR5cCI6IkJlYXJlciJ9.U010q6KUB04K8rIU9rVnW_AOI1q5XSXSGIYdL1moaOA", + "expires_in": 7200000, + "token_type": "Bearer" +} +``` + +### Sign in by Refresh Token Flow + +**Request**: + +```sh +curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/token \ + -H "Content-Type: application/json" \ + -d '{"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjM2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210OG5vbjRkZHQ5YzgwMDAxcTEiLCJuYmYiOjE2MDA3OTAzNjcsInR5cCI6IkJlYXJlciJ9.U010q6KUB04K8rIU9rVnW_AOI1q5XSXSGIYdL1moaOA", "grant_type": "refresh_token"}' +``` + +**Response (200)**: + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDc5NzgwOSwiaWF0IjoxNjAwNzkwNjA5LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpZDUwYXRja3JiMzMyZWswMDAxczEiLCJuYmYiOjE2MDA3OTA2MDksInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.GnuyK5JTgg0PCeUtT79s847a3qPWgBjE8UqYoK1DG8o", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpZDUwYXRja3JiMzMyZWswMDAxczEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjYwOSwiaWF0IjoxNjAwNzkwNjA5LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpZDUwYXRpOHJ2MzMyZWswMDAxdDEiLCJuYmYiOjE2MDA3OTA2MDksInR5cCI6IkJlYXJlciJ9.HIL0AMMKJdYUibSXyYXfYGBEMIZsuudvFUHcF-VjXRg", + "expires_in": 7200000, + "token_type": "Bearer" +} +``` + +### Sign in by Client Credentials Flow + +**Request**: + +```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_secret": "$2b$12$BSrTLJnb0Vfuk1iiSzw3MehAvgztbMYpnhneVLQhkoZbxAXBGUCFe"}' +``` + +**Response (200)**: + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDc5NzU2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJuYmYiOjE2MDA3OTAzNjcsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.LWniDC38j2kW8ER8kgDnVVJO0eOXWGNq0KqXooMl-5s", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdGkiOiIyb3JpY210ODQ3NTg1ZHQ5YzgwMDAxcDEiLCJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMzM4MjM2NywiaWF0IjoxNjAwNzkwMzY3LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JpY210OG5vbjRkZHQ5YzgwMDAxcTEiLCJuYmYiOjE2MDA3OTAzNjcsInR5cCI6IkJlYXJlciJ9.U010q6KUB04K8rIU9rVnW_AOI1q5XSXSGIYdL1moaOA", + "expires_in": 7200000, + "token_type": "Bearer" +} +``` + +### Sign out all active sessions + +**Request**: + +```sh +curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/logout-all-sessions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMTE1NzEyNCwiaWF0IjoxNjAxMTQ5OTI0LCJpZGVudGl0eSI6InVzZXIiLCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3M2cW5zN2ZxYjFvOGhrZDQwMDAxNTQiLCJuYmYiOjE2MDExNDk5MjQsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjIyZTk2MTA4LThkZDYtNGZiZS1iMjExLTY4OTM0YmJhNWJkNyIsInR0bCI6NzIwMCwidHlwIjoiQmVhcmVyIn0.EuIJtx_AGLrL2O7E7cBfsvEQymalO_A5-J0BX4PODwk" +``` + +**Response (204)**: + +`No content` + +### Sign out the given session + +**Request**: + +```sh +curl -X POST http://localhost:4000/api/v1/auth/protocol/openid-connect/logout \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIyZTQ1NWJiMS0wNjA0LTQ4MTItOTc1Ni0zNmY3YWIyM2I4ZDkiLCJhenAiOiJhZG1pbiIsImV4cCI6MTYwMDgyMzMxNiwiaWF0IjoxNjAwODE2MTE2LCJpc3MiOiJXYXRjaGVyRXgiLCJqdGkiOiIyb3JqcmhuMHNxdDlncjk3ZXMwMDAzMDMiLCJuYmYiOjE2MDA4MTYxMTYsInNjb3BlIjoiYWRtaW46cmVhZCBhZG1pbjp3cml0ZSIsInN1YiI6IjdmNWViOWRjLWI1NTAtNDU4Ni05MWRjLTNjNzAxZWIzYjliYyIsInR5cCI6IkJlYXJlciJ9.NxFH6MIOFGc54UR9EVLPFB0m-6b-YMyXhZrOuGxErdw" +``` + +**Response (204)**: + +`No content` \ No newline at end of file diff --git a/apps/rest_api/lib/controllers/public/auth.ex b/apps/rest_api/lib/controllers/public/auth.ex index cc9f4a0..47e12d8 100644 --- a/apps/rest_api/lib/controllers/public/auth.ex +++ b/apps/rest_api/lib/controllers/public/auth.ex @@ -14,48 +14,42 @@ defmodule RestAPI.Controllers.Public.Auth do The accepted flow are: - Resource Owner (Authenticates using username and password); - Refresh Token (Authenticates using an refresh token); + - Client Credentials (Authenticates using client_id and secret); """ @spec sign_in(conn :: Plug.Conn.t(), params :: map()) :: Plug.Conn.t() def sign_in(conn, %{"grant_type" => "password"} = params) do params |> Commands.sign_in_resource_owner() - |> case do - {:ok, response} -> - conn - |> put_status(:ok) - |> put_view(SignIn) - |> render("sign_in.json", response: response) - - {:error, _reason} = error -> - error - end + |> parse_sign_in_response(conn) end def sign_in(conn, %{"grant_type" => "refresh_token"} = params) do params |> Commands.sign_in_refresh_token() - |> case do - {:ok, response} -> - conn - |> put_status(:ok) - |> put_view(SignIn) - |> render("sign_in.json", response: response) + |> parse_sign_in_response(conn) + end - {:error, _reason} = error -> - error - end + def sign_in(conn, %{"grant_type" => "client_credentials"} = params) do + params + |> Commands.sign_in_client_credentials() + |> parse_sign_in_response(conn) + end + + defp parse_sign_in_response({:error, _any} = error, _conn), do: error + + defp parse_sign_in_response({:ok, response}, conn) do + conn + |> put_status(:ok) + |> put_view(SignIn) + |> render("sign_in.json", response: response) end @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.jti + session |> Commands.sign_out_session() - |> case do - {:ok, _count} -> send_resp(conn, :no_content, "") - {:error, :not_active} -> send_resp(conn, :forbidden, "") - {:error, :not_found} -> send_resp(conn, :not_found, "") - end + |> parse_sign_out_response(conn) end @doc "Logout subject authenticated sessions." @@ -63,10 +57,10 @@ defmodule RestAPI.Controllers.Public.Auth do def sign_out_all_sessions(%{private: %{session: session}} = conn, _params) do session.subject_id |> Commands.sign_out_all_sessions(session.subject_type) - |> case do - {:ok, _count} -> send_resp(conn, :no_content, "") - {:error, :not_active} -> send_resp(conn, :forbidden, "") - {:error, :not_found} -> send_resp(conn, :not_found, "") - end + |> parse_sign_out_response(conn) end + + 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, "") end diff --git a/apps/rest_api/lib/plugs/authentication.ex b/apps/rest_api/lib/plugs/authentication.ex index 7e6c046..f2b4120 100644 --- a/apps/rest_api/lib/plugs/authentication.ex +++ b/apps/rest_api/lib/plugs/authentication.ex @@ -20,7 +20,7 @@ defmodule RestAPI.Plugs.Authentication do with {:header, [access_token | _]} <- {:header, get_req_header(conn, "authorization")}, {:bearer, "Bearer " <> access_token} <- {:bearer, access_token}, {:token, {:ok, claims}} <- {:token, Authenticator.validate_access_token(access_token)}, - {:session, {:ok, session}} <- {:session, Authenticator.get_session(claims["jti"])} do + {:session, {:ok, session}} <- {:session, Authenticator.get_session(claims)} do put_private(conn, :session, build_payload(session)) else {:header, []} -> @@ -38,10 +38,6 @@ defmodule RestAPI.Plugs.Authentication do {:session, _any} -> Logger.info("Session was not found") Fallback.call(conn, {:error, :unauthenticated}) - - error -> - Logger.error("Failed to authenticate because of an unknow error") - Fallback.call(conn, error) end end diff --git a/apps/rest_api/lib/ports/authenticator.ex b/apps/rest_api/lib/ports/authenticator.ex index 4cdeff9..0ab14d7 100644 --- a/apps/rest_api/lib/ports/authenticator.ex +++ b/apps/rest_api/lib/ports/authenticator.ex @@ -25,6 +25,9 @@ defmodule RestAPI.Ports.Authenticator do @doc "Delegates to Authenticator.sign_in_refresh_token/1" @callback sign_in_refresh_token(input :: map()) :: possible_sign_in_responses() + @doc "Delegates to Authenticator.sign_in_client_credentials/1" + @callback sign_in_client_credentials(input :: map()) :: possible_sign_in_responses() + @doc "Delegates to Authenticator.get_session/1" @callback get_session(input :: map()) :: struct() @@ -47,6 +50,10 @@ defmodule RestAPI.Ports.Authenticator do @spec sign_in_refresh_token(input :: map()) :: possible_sign_in_responses() def sign_in_refresh_token(input), do: implementation().sign_in_refresh_token(input) + @doc "Authenticates the subject using Client Credentials Flow" + @spec sign_in_client_credentials(input :: map()) :: possible_sign_in_responses() + def sign_in_client_credentials(input), do: implementation().sign_in_client_credentials(input) + @doc "Validates a given access token and it's claims" @spec validate_access_token(access_token :: String.t()) :: {:ok, map()} | {:error, Keyword.t()} def validate_access_token(token), do: implementation().validate_access_token(token) diff --git a/apps/rest_api/test/controllers/fallback_test.exs b/apps/rest_api/test/controllers/fallback_test.exs index a1d1247..0c1e30c 100644 --- a/apps/rest_api/test/controllers/fallback_test.exs +++ b/apps/rest_api/test/controllers/fallback_test.exs @@ -1,4 +1,4 @@ -defmodule RestAPI.RestAPI.Controllers.FallbackTest do +defmodule RestAPI.Controllers.FallbackTest do use RestAPI.ConnCase, async: true alias ResourceManager.Identity.Commands.Inputs.CreateUser diff --git a/apps/rest_api/test/controllers/public/auth_test.exs b/apps/rest_api/test/controllers/public/auth_test.exs index 6104cce..890be70 100644 --- a/apps/rest_api/test/controllers/public/auth_test.exs +++ b/apps/rest_api/test/controllers/public/auth_test.exs @@ -1,7 +1,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do use RestAPI.ConnCase, async: true - alias Authenticator.SignIn.Inputs.{RefreshToken, ResourceOwner} + alias Authenticator.SignIn.Inputs.{ClientCredentials, RefreshToken, ResourceOwner} alias RestAPI.Ports.AuthenticatorMock @token_endpoint "/api/v1/auth/protocol/openid-connect/token" @@ -45,6 +45,24 @@ defmodule RestAPI.Controllers.Public.AuthTest do |> json_response(200) end + test "suceeds in Client Credentials Flow if params are valid", %{conn: conn} do + params = %{ + "grant_type" => "client_credentials", + "scope" => "admin:read admin:write", + "client_id" => "2e455bb1-0604-4812-9756-36f7ab23b8d9", + "client_secret" => "w3MehAvgztbMYpnhneVLQhkoZbxAXBGUCFe" + } + + expect(AuthenticatorMock, :sign_in_client_credentials, fn _input -> + {:ok, success_payload()} + end) + + assert %{"access_token" => _, "refresh_token" => _, "token_type" => _, "expires_in" => _} = + conn + |> post(@token_endpoint, params) + |> json_response(200) + end + test "fails in Resource Owner Flow if params are invalid", %{conn: conn} do expect(AuthenticatorMock, :sign_in_resource_owner, fn input when is_map(input) -> ResourceOwner.cast_and_apply(input) @@ -74,6 +92,23 @@ defmodule RestAPI.Controllers.Public.AuthTest do |> post(@token_endpoint, %{"grant_type" => "refresh_token"}) |> json_response(400) end + + test "fails in Client Credentials Flow if params are invalid", %{conn: conn} do + expect(AuthenticatorMock, :sign_in_client_credentials, fn input when is_map(input) -> + ClientCredentials.cast_and_apply(input) + end) + + assert %{ + "response" => %{ + "scope" => ["can't be blank"], + "client_id" => ["can't be blank"], + "client_secret" => ["can't be blank"] + } + } = + conn + |> post(@token_endpoint, %{"grant_type" => "client_credentials"}) + |> json_response(400) + end end describe "POST #{@logout_endpoint}" do @@ -87,12 +122,12 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, claims} end) - expect(AuthenticatorMock, :get_session, fn jti -> + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> assert claims["jti"] == jti {:ok, success_session(claims)} end) - expect(AuthenticatorMock, :sign_out_session, fn jti -> + expect(AuthenticatorMock, :sign_out_session, fn %{jti: jti} -> assert claims["jti"] == jti {:ok, %{}} end) @@ -103,18 +138,18 @@ defmodule RestAPI.Controllers.Public.AuthTest do |> response(204) end - test "fails if sesions not active", %{conn: conn, access_token: access_token, claims: claims} do + test "fails if sessions not active", %{conn: conn, access_token: access_token, claims: claims} do expect(AuthenticatorMock, :validate_access_token, fn token -> assert access_token == token {:ok, claims} end) - expect(AuthenticatorMock, :get_session, fn jti -> + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> assert claims["jti"] == jti {:ok, success_session(claims)} end) - expect(AuthenticatorMock, :sign_out_session, fn jti -> + expect(AuthenticatorMock, :sign_out_session, fn %{jti: jti} -> assert claims["jti"] == jti {:error, :not_active} end) @@ -131,12 +166,12 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, claims} end) - expect(AuthenticatorMock, :get_session, fn jti -> + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> assert claims["jti"] == jti {:ok, success_session(claims)} end) - expect(AuthenticatorMock, :sign_out_session, fn jti -> + expect(AuthenticatorMock, :sign_out_session, fn %{jti: jti} -> assert claims["jti"] == jti {:error, :not_found} end) @@ -158,7 +193,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, claims} end) - expect(AuthenticatorMock, :get_session, fn jti -> + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> assert claims["jti"] == jti {:ok, success_session(claims)} end) @@ -184,7 +219,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, claims} end) - expect(AuthenticatorMock, :get_session, fn jti -> + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> assert claims["jti"] == jti {:ok, success_session(claims)} end) @@ -210,7 +245,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do {:ok, claims} end) - expect(AuthenticatorMock, :get_session, fn jti -> + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> assert claims["jti"] == jti {:ok, success_session(claims)} end) @@ -230,6 +265,7 @@ defmodule RestAPI.Controllers.Public.AuthTest do defp default_claims do %{ + "jti" => "03eds74a-c291-4b5f", "aud" => "02eff74a-c291-4b5f-a02f-4f92d8daf693", "azp" => "my-application", "sub" => "272459ce-7356-4460-b461-1ecf0ebf7c4e", diff --git a/apps/rest_api/test/plugs/authentication_test.exs b/apps/rest_api/test/plugs/authentication_test.exs new file mode 100644 index 0000000..9087ef1 --- /dev/null +++ b/apps/rest_api/test/plugs/authentication_test.exs @@ -0,0 +1,112 @@ +defmodule RestAPI.Plugs.AuthenticationTest do + use RestAPI.ConnCase, async: true + + alias RestAPI.Plugs.Authentication + alias RestAPI.Ports.AuthenticatorMock + + describe "#{Authentication}.init/1" do + test "returns the given conn" do + assert [] == Authentication.init([]) + end + end + + describe "#{Authentication}.call/2" do + test "succeeds and authenticate the session", %{conn: conn} do + access_token = "my-token" + claims = default_claims() + + expect(AuthenticatorMock, :validate_access_token, fn token -> + assert access_token == token + {:ok, claims} + end) + + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> + assert claims["jti"] == jti + {:ok, success_session(claims)} + end) + + assert %Plug.Conn{private: %{session: session}} = + conn + |> put_req_header("authorization", "Bearer #{access_token}") + |> Authentication.call([]) + + assert claims["jti"] == session.jti + end + + test "fails if header is invalid", %{conn: conn} do + assert %Plug.Conn{status: 403} = Authentication.call(conn, []) + end + + test "fails if token is not bearer invalid", %{conn: conn} do + assert %Plug.Conn{status: 403} = + conn + |> put_req_header("authorization", "my-token") + |> Authentication.call([]) + end + + test "fails if token is not valid", %{conn: conn} do + access_token = "my-token" + + expect(AuthenticatorMock, :validate_access_token, fn token -> + assert access_token == token + {:error, :invalid_signature} + end) + + assert %Plug.Conn{status: 403} = + conn + |> put_req_header("authorization", "Bearer #{access_token}") + |> Authentication.call([]) + end + + test "fails if session not found", %{conn: conn} do + access_token = "my-token" + claims = default_claims() + + expect(AuthenticatorMock, :validate_access_token, fn token -> + assert access_token == token + {:ok, claims} + end) + + expect(AuthenticatorMock, :get_session, fn %{"jti" => jti} -> + assert claims["jti"] == jti + {:error, :not_found} + end) + + assert %Plug.Conn{status: 403} = + conn + |> put_req_header("authorization", "Bearer #{access_token}") + |> Authentication.call([]) + end + end + + defp default_claims do + %{ + "jti" => "03eds74a-c291-4b5f", + "aud" => "02eff74a-c291-4b5f-a02f-4f92d8daf693", + "azp" => "my-application", + "sub" => "272459ce-7356-4460-b461-1ecf0ebf7c4e", + "typ" => "Bearer", + "identity" => "user", + "scope" => "admin:read" + } + end + + defp success_session(claims) do + %{ + id: "02eff44a-c291-4b5f-a02f-4f92d8dbf693", + jti: claims["jti"], + subject_id: claims["sub"], + subject_type: claims["identity"], + expires_at: claims["expires_at"], + scopes: parse_scopes(claims["scope"]), + azp: claims["azp"], + claims: claims + } + end + + defp parse_scopes(scope) when is_binary(scope) do + scope + |> String.split(" ", trim: true) + |> Enum.map(& &1) + end +end