From ed5437c8818fe52bbdb725be0a49a7819122fb65 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Tue, 9 Jan 2024 13:36:21 -0600 Subject: [PATCH] security(portal): Rework auth tokens (#2696) - [x] make sure that session cookie for client is stored separately from session cookie for the portal (will close #2647 and #2032) - [x] #2622 - [ ] #2501 - [ ] show identity tokens and allow rotating/deleting them (#2138) - [ ] #2042 - [ ] use Tokens context for Relays and Gateways to remove duplication - [x] #2823 - [ ] Expire LiveView sockets when subject is expired - [ ] Service Accounts UI is ambiguous now because of token identity and actual token shown - [ ] Limit subject permissions based on token type Closes #2924. Now we extend the lifetime for client tokens, but not for browsers. --- docker-compose.yml | 18 +- elixir/README.md | 8 +- elixir/apps/api/lib/api/client/socket.ex | 3 +- elixir/apps/api/lib/api/session.ex | 33 - .../apps/api/test/api/client/socket_test.exs | 118 +- elixir/apps/domain/lib/domain/accounts.ex | 3 + .../domain/lib/domain/accounts/account.ex | 2 + elixir/apps/domain/lib/domain/actors.ex | 4 +- elixir/apps/domain/lib/domain/application.ex | 1 + elixir/apps/domain/lib/domain/auth.ex | 719 +++---- elixir/apps/domain/lib/domain/auth/adapter.ex | 4 + .../domain/lib/domain/auth/adapters/email.ex | 4 +- .../domain/lib/domain/auth/adapters/token.ex | 4 +- .../lib/domain/auth/adapters/userpass.ex | 2 +- .../adapters/userpass/password/changeset.ex | 2 +- elixir/apps/domain/lib/domain/auth/context.ex | 5 +- .../lib/domain/auth/identity/changeset.ex | 2 +- .../domain/lib/domain/auth/identity/query.ex | 19 +- .../domain/lib/domain/auth/provider/query.ex | 16 +- elixir/apps/domain/lib/domain/auth/roles.ex | 3 +- elixir/apps/domain/lib/domain/auth/subject.ex | 6 +- elixir/apps/domain/lib/domain/clients.ex | 14 +- .../domain/lib/domain/config/definitions.ex | 14 +- elixir/apps/domain/lib/domain/crypto.ex | 35 +- elixir/apps/domain/lib/domain/gateways.ex | 2 +- .../lib/domain/gateways/gateway/changeset.ex | 3 +- .../lib/domain/gateways/token/changeset.ex | 2 +- elixir/apps/domain/lib/domain/jobs.ex | 2 +- elixir/apps/domain/lib/domain/relays.ex | 2 +- .../lib/domain/relays/token/changeset.ex | 2 +- elixir/apps/domain/lib/domain/tokens.ex | 215 ++ .../domain/lib/domain/tokens/authorizer.ex | 38 + elixir/apps/domain/lib/domain/tokens/jobs.ex | 10 + elixir/apps/domain/lib/domain/tokens/token.ex | 38 + .../lib/domain/tokens/token/changeset.ex | 97 + .../domain/lib/domain/tokens/token/query.ex | 50 + elixir/apps/domain/lib/domain/validator.ex | 38 +- .../migrations/20230920172332_add_tokens.exs | 53 + elixir/apps/domain/priv/repo/seeds.exs | 74 +- .../apps/domain/test/domain/accounts_test.exs | 6 +- .../apps/domain/test/domain/actors_test.exs | 46 +- .../test/domain/auth/adapters/email_test.exs | 15 +- .../test/domain/auth/adapters/token_test.exs | 4 +- .../domain/auth/adapters/userpass_test.exs | 2 +- elixir/apps/domain/test/domain/auth_test.exs | 1746 +++++++++++++---- .../apps/domain/test/domain/clients_test.exs | 92 +- .../apps/domain/test/domain/config_test.exs | 8 +- .../apps/domain/test/domain/crypto_test.exs | 58 + elixir/apps/domain/test/domain/flows_test.exs | 30 +- .../apps/domain/test/domain/gateways_test.exs | 41 +- .../apps/domain/test/domain/policies_test.exs | 57 +- .../apps/domain/test/domain/relays_test.exs | 24 +- .../domain/test/domain/resources_test.exs | 78 +- .../apps/domain/test/domain/tokens_test.exs | 558 ++++++ .../apps/domain/test/support/fixtures/auth.ex | 177 +- .../domain/test/support/fixtures/tokens.ex | 105 + elixir/apps/web/lib/web/auth.ex | 373 ++-- .../web/lib/web/components/core_components.ex | 15 +- .../lib/web/components/table_components.ex | 32 +- .../lib/web/controllers/auth_controller.ex | 276 ++- .../lib/web/controllers/home_controller.ex | 19 +- .../apps/web/lib/web/controllers/home_html.ex | 21 +- elixir/apps/web/lib/web/endpoint.ex | 3 + elixir/apps/web/lib/web/live/actors/edit.ex | 16 +- elixir/apps/web/lib/web/live/actors/index.ex | 19 +- .../web/live/actors/service_accounts/new.ex | 2 +- .../actors/service_accounts/new_identity.ex | 10 +- elixir/apps/web/lib/web/live/actors/show.ex | 111 +- .../apps/web/lib/web/live/actors/users/new.ex | 2 +- .../lib/web/live/actors/users/new_identity.ex | 2 +- elixir/apps/web/lib/web/live/clients/edit.ex | 3 +- elixir/apps/web/lib/web/live/flows/show.ex | 2 +- elixir/apps/web/lib/web/live/groups/edit.ex | 3 +- elixir/apps/web/lib/web/live/groups/index.ex | 14 +- elixir/apps/web/lib/web/live/groups/new.ex | 4 +- .../web/lib/web/live/relay_groups/edit.ex | 3 +- .../apps/web/lib/web/live/relay_groups/new.ex | 4 +- .../settings/identity_providers/components.ex | 3 - .../google_workspace/connect.ex | 8 +- .../google_workspace/edit.ex | 2 +- .../google_workspace/new.ex | 2 +- .../live/settings/identity_providers/index.ex | 19 +- .../live/settings/identity_providers/new.ex | 11 +- .../openid_connect/connect.ex | 12 +- .../identity_providers/openid_connect/edit.ex | 2 +- .../identity_providers/openid_connect/new.ex | 2 +- .../identity_providers/saml/components.ex | 220 --- .../settings/identity_providers/saml/saml.ex | 115 -- .../settings/identity_providers/saml/show.ex | 168 -- elixir/apps/web/lib/web/live/sign_in.ex | 47 +- elixir/apps/web/lib/web/live/sign_in/email.ex | 47 +- elixir/apps/web/lib/web/live/sign_up.ex | 4 +- elixir/apps/web/lib/web/live/sites/edit.ex | 3 +- elixir/apps/web/lib/web/live/sites/new.ex | 4 +- elixir/apps/web/lib/web/router.ex | 19 +- elixir/apps/web/lib/web/session.ex | 2 +- .../web/test/support/acceptance_case/auth.ex | 71 +- elixir/apps/web/test/support/conn_case.ex | 66 +- .../web/acceptance/auth/userpass_test.exs | 4 +- elixir/apps/web/test/web/auth_test.exs | 786 ++++++-- .../web/controllers/auth_controller_test.exs | 1003 ++++------ .../web/test/web/live/actors/edit_test.exs | 12 +- .../web/test/web/live/actors/index_test.exs | 6 +- .../web/test/web/live/actors/new_test.exs | 6 +- .../service_accounts/new_identity_test.exs | 9 +- .../live/actors/service_accounts/new_test.exs | 6 +- .../web/test/web/live/actors/show_test.exs | 285 ++- .../live/actors/users/new_identity_test.exs | 6 +- .../test/web/live/actors/users/new_test.exs | 6 +- .../web/test/web/live/clients/edit_test.exs | 6 +- .../web/test/web/live/clients/index_test.exs | 6 +- .../web/test/web/live/clients/show_test.exs | 12 +- .../web/test/web/live/gateways/show_test.exs | 11 +- .../test/web/live/groups/edit_actors_test.exs | 6 +- .../web/test/web/live/groups/edit_test.exs | 6 +- .../web/test/web/live/groups/index_test.exs | 6 +- .../web/test/web/live/groups/new_test.exs | 6 +- .../web/test/web/live/groups/show_test.exs | 12 +- .../web/test/web/live/policies/edit_test.exs | 6 +- .../web/test/web/live/policies/index_test.exs | 6 +- .../web/test/web/live/policies/new_test.exs | 6 +- .../web/test/web/live/policies/show_test.exs | 12 +- .../test/web/live/relay_groups/edit_test.exs | 6 +- .../test/web/live/relay_groups/index_test.exs | 6 +- .../test/web/live/relay_groups/new_test.exs | 6 +- .../test/web/live/relay_groups/show_test.exs | 12 +- .../web/test/web/live/relays/show_test.exs | 10 +- .../web/test/web/live/resources/edit_test.exs | 6 +- .../test/web/live/resources/index_test.exs | 6 +- .../web/test/web/live/resources/new_test.exs | 6 +- .../web/test/web/live/resources/show_test.exs | 11 +- .../web/live/settings/account/index_test.exs | 6 +- .../test/web/live/settings/dns/index_test.exs | 6 +- .../google_workspace/connect_test.exs | 32 +- .../google_workspace/edit_test.exs | 9 +- .../google_workspace/new_test.exs | 6 +- .../google_workspace/show_test.exs | 15 +- .../identity_providers/index_test.exs | 6 +- .../settings/identity_providers/new_test.exs | 6 +- .../openid_connect/connect_test.exs | 24 +- .../openid_connect/edit_test.exs | 6 +- .../openid_connect/new_test.exs | 6 +- .../openid_connect/show_test.exs | 13 +- .../identity_providers/system/show_test.exs | 13 +- .../web/test/web/live/sign_in/email_test.exs | 140 +- .../apps/web/test/web/live/sign_in_test.exs | 22 +- .../web/test/web/live/sites/edit_test.exs | 6 +- .../web/test/web/live/sites/index_test.exs | 6 +- .../apps/web/test/web/live/sites/new_test.exs | 6 +- .../web/test/web/live/sites/show_test.exs | 12 +- elixir/config/config.exs | 14 +- elixir/config/runtime.exs | 8 +- terraform/environments/production/main.tf | 12 +- terraform/environments/staging/main.tf | 12 +- 154 files changed, 5988 insertions(+), 3170 deletions(-) delete mode 100644 elixir/apps/api/lib/api/session.ex create mode 100644 elixir/apps/domain/lib/domain/tokens.ex create mode 100644 elixir/apps/domain/lib/domain/tokens/authorizer.ex create mode 100644 elixir/apps/domain/lib/domain/tokens/jobs.ex create mode 100644 elixir/apps/domain/lib/domain/tokens/token.ex create mode 100644 elixir/apps/domain/lib/domain/tokens/token/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/tokens/token/query.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20230920172332_add_tokens.exs create mode 100644 elixir/apps/domain/test/domain/tokens_test.exs create mode 100644 elixir/apps/domain/test/support/fixtures/tokens.ex delete mode 100644 elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex delete mode 100644 elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex delete mode 100644 elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex diff --git a/docker-compose.yml b/docker-compose.yml index 336c76f1d..06f960214 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,8 +81,8 @@ services: # Auth AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace" # Secrets - AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" - AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" + TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" + TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" RELAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" RELAYS_AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" GATEWAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" @@ -113,7 +113,7 @@ services: client: environment: - FIREZONE_TOKEN: "SFMyNTY.g2gDaAN3CGlkZW50aXR5bQAAACQ3ZGE3ZDFjZC0xMTFjLTQ0YTctYjVhYy00MDI3YjlkMjMwZTVtAAAAIBn8Xu1jtFlxZxp4ZvAz0f0QEN2PZThA-7awHMPxn_tHbgYAbLRvQokBYgHhM38.pM-prhb7uvvCVKf51-tAUMEtMzLPZk1n3nLsY44dGFA" + FIREZONE_TOKEN: "n.SFMyNTY.g2gDaANtAAAAJGM4OWJjYzhjLTkzOTItNGRhZS1hNDBkLTg4OGFlZjZkMjhlMG0AAAAkN2RhN2QxY2QtMTExYy00NGE3LWI1YWMtNDAyN2I5ZDIzMGU1bQAAACtBaUl5XzZwQmstV0xlUkFQenprQ0ZYTnFJWktXQnMyRGR3XzJ2Z0lRdkZnbgYAGUmu74wBYgABUYA.UN3vSLLcAMkHeEh5VHumPOutkuue8JA6wlxM9JxJEPE" RUST_LOG: firezone_linux_client=trace,wire=trace,connlib_client_shared=trace,firezone_tunnel=trace,connlib_shared=trace,warn FIREZONE_API_URL: ws://api:8081 FIREZONE_ID: D0455FDE-8F65-4960-A778-B934E4E85A5F @@ -223,7 +223,7 @@ services: image: us-east1-docker.pkg.dev/firezone-staging/firezone/relay:${VERSION:-main} healthcheck: test: ["CMD-SHELL", "lsof -i UDP | grep firezone-relay"] - start_period: 20s + start_period: 3s interval: 30s retries: 5 timeout: 5s @@ -273,8 +273,8 @@ services: # Auth AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace" # Secrets - AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" - AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" + TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" + TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" RELAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" RELAYS_AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" GATEWAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" @@ -302,7 +302,7 @@ services: condition: "service_healthy" healthcheck: test: ["CMD-SHELL", "curl -f localhost:8081/healthz"] - start_period: 20s + start_period: 10s interval: 30s retries: 5 timeout: 5s @@ -338,8 +338,8 @@ services: # Auth AUTH_PROVIDER_ADAPTERS: "email,openid_connect,userpass,token,google_workspace" # Secrets - AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" - AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" + TOKENS_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" + TOKENS_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" RELAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" RELAYS_AUTH_TOKEN_SALT: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej2" GATEWAYS_AUTH_TOKEN_KEY_BASE: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S2" diff --git a/elixir/README.md b/elixir/README.md index afc29b8bf..deddca742 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -102,7 +102,7 @@ Now you can verify that it's working by connecting to a websocket: ```bash # Note: The token value below is an example. The token value you will need is generated and printed out when seeding the database, as described earlier in the document. -❯ export CLIENT_TOKEN_FROM_SEEDS="SFMyNTY.g2gDaAN3CGlkZW50aXR5bQAAACQ3ZGE3ZDFjZC0xMTFjLTQ0YTctYjVhYy00MDI3YjlkMjMwZTV3Bmlnbm9yZW4GAJhGr7WKAWIACTqA.mrPu5eFVwkfRml7zzHb5uYfosLGaYVHq03-wE02xUNc" +❯ export CLIENT_TOKEN_FROM_SEEDS="n.SFMyNTY.g2gDaANtAAAAJGM4OWJjYzhjLTkzOTItNGRhZS1hNDBkLTg4OGFlZjZkMjhlMG0AAAAkN2RhN2QxY2QtMTExYy00NGE3LWI1YWMtNDAyN2I5ZDIzMGU1bQAAACtBaUl5XzZwQmstV0xlUkFQenprQ0ZYTnFJWktXQnMyRGR3XzJ2Z0lRdkZnbgYAGUmu74wBYgABUYA.UN3vSLLcAMkHeEh5VHumPOutkuue8JA6wlxM9JxJEPE" # Panel will only accept token if it's coming with this User-Agent header and from IP 172.28.0.1 ❯ export CLIENT_USER_AGENT="iOS/12.5 (iPhone) connlib/0.7.412" @@ -209,12 +209,12 @@ user_agent = "User-Agent: iOS/12.7 (iPhone) connlib/0.7.412" remote_ip = {127, 0, 0, 1} # For a client -{:ok, subject} = Domain.Auth.sign_in(client_token, user_agent, remote_ip) +{:ok, subject} = Domain.Auth.sign_in(client_token, %Domain.Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip}) # For an admin user provider = Domain.Repo.get_by(Domain.Auth.Provider, adapter: :userpass) identity = Domain.Repo.get_by(Domain.Auth.Identity, provider_id: provider.id, provider_identifier: "firezone@localhost") -subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{user_agent: user_agent, remote_ip: remote_ip}) +subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip}) ``` Listing connected gateways, relays, clients for an account: @@ -340,7 +340,7 @@ iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)1> [actor | _] = Domain. iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)2> [identity | _] = Domain.Auth.Identity.Query.by_actor_id(actor.id) |> Domain.Repo.all() ... -iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)3> subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{user_agent: "CLI", remote_ip: {127, 0, 0, 1}}) +iex(web@web-2f4j.us-east1-d.c.firezone-staging.internal)3> subject = Domain.Auth.build_subject(identity, nil, %Domain.Auth.Context{type: :browser, user_agent: "CLI", remote_ip: {127, 0, 0, 1}}) ``` ### Rotate relay token diff --git a/elixir/apps/api/lib/api/client/socket.ex b/elixir/apps/api/lib/api/client/socket.ex index 59489c461..c938ac0cb 100644 --- a/elixir/apps/api/lib/api/client/socket.ex +++ b/elixir/apps/api/lib/api/client/socket.ex @@ -27,6 +27,7 @@ defmodule API.Client.Socket do API.Sockets.load_balancer_ip_location(x_headers) context = %Auth.Context{ + type: :client, user_agent: user_agent, remote_ip: real_ip, remote_ip_location_region: location_region, @@ -35,7 +36,7 @@ defmodule API.Client.Socket do remote_ip_location_lon: location_lon } - with {:ok, subject} <- Auth.sign_in(token, context), + with {:ok, subject} <- Auth.authenticate(token, context), {:ok, client} <- Clients.upsert_client(attrs, subject) do OpenTelemetry.Tracer.set_attributes(%{ client_id: client.id, diff --git a/elixir/apps/api/lib/api/session.ex b/elixir/apps/api/lib/api/session.ex deleted file mode 100644 index d8aafee2f..000000000 --- a/elixir/apps/api/lib/api/session.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule API.Session do - def options do - [ - store: :cookie, - key: "_firezone_api_key", - same_site: "Lax", - # 4 hours - max_age: 14_400, - sign: true, - encrypt: true, - secure: cookie_secure(), - signing_salt: signing_salt(), - encryption_salt: encryption_salt() - ] - end - - defp cookie_secure do - Domain.Config.fetch_env!(:api, :cookie_secure) - end - - defp signing_salt do - [vsn | _] = - Application.spec(:domain, :vsn) - |> to_string() - |> String.split("+") - - Domain.Config.fetch_env!(:api, :cookie_signing_salt) <> vsn - end - - defp encryption_salt do - Domain.Config.fetch_env!(:api, :cookie_encryption_salt) - end -end diff --git a/elixir/apps/api/test/api/client/socket_test.exs b/elixir/apps/api/test/api/client/socket_test.exs index 9d1fdce43..6239f872f 100644 --- a/elixir/apps/api/test/api/client/socket_test.exs +++ b/elixir/apps/api/test/api/client/socket_test.exs @@ -2,7 +2,6 @@ defmodule API.Client.SocketTest do use API.ChannelCase, async: true import API.Client.Socket, only: [id: 1] alias API.Client.Socket - alias Domain.Auth @geo_headers [ {"x-geo-location-region", "Ukraine"}, @@ -31,31 +30,74 @@ defmodule API.Client.SocketTest do end test "renders error on invalid attrs" do - subject = Fixtures.Auth.create_subject() - {:ok, token} = Auth.create_session_token_from_subject(subject) + context = Fixtures.Auth.build_context(type: :client) + {_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context) - attrs = %{token: token} + attrs = %{token: encoded_token} - assert {:error, changeset} = connect(Socket, attrs, connect_info: connect_info(subject)) + assert {:error, changeset} = connect(Socket, attrs, connect_info: @connect_info) errors = API.Sockets.changeset_error_to_string(changeset) assert errors =~ "public_key: can't be blank" assert errors =~ "external_id: can't be blank" end - test "creates a new client" do - subject = Fixtures.Auth.create_subject() - {:ok, token} = Auth.create_session_token_from_subject(subject) + test "returns error when token is created for a different context" do + context = Fixtures.Auth.build_context(type: :browser) + {_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context) - attrs = connect_attrs(token: token) + attrs = connect_attrs(token: encoded_token) - assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(subject)) + assert connect(Socket, attrs, connect_info: @connect_info) == {:error, :invalid_token} + end + + test "creates a new client for user identity" do + context = Fixtures.Auth.build_context(type: :client) + {_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context) + + attrs = connect_attrs(token: encoded_token) + + assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(context)) assert client = Map.fetch!(socket.assigns, :client) assert client.external_id == attrs["external_id"] assert client.public_key == attrs["public_key"] - assert client.last_seen_user_agent == subject.context.user_agent - assert client.last_seen_remote_ip.address == subject.context.remote_ip + assert client.last_seen_user_agent == context.user_agent + assert client.last_seen_remote_ip.address == context.remote_ip + assert client.last_seen_remote_ip_location_region == "Ukraine" + assert client.last_seen_remote_ip_location_city == "Kyiv" + assert client.last_seen_remote_ip_location_lat == 50.4333 + assert client.last_seen_remote_ip_location_lon == 30.5167 + assert client.last_seen_version == "0.7.412" + end + + test "creates a new client for service account identity" do + context = Fixtures.Auth.build_context(type: :client) + account = Fixtures.Accounts.create_account() + provider = Fixtures.Auth.create_token_provider(account: account) + + identity = + Fixtures.Auth.create_identity( + account: account, + provider: provider, + provider_virtual_state: %{ + "expires_at" => DateTime.utc_now() |> DateTime.add(60, :second) + } + ) + + subject = Fixtures.Auth.create_subject(account: account, actor: [type: :account_admin_user]) + + {:ok, encoded_token} = Domain.Auth.create_service_account_token(provider, identity, subject) + + attrs = connect_attrs(token: encoded_token) + + assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(context)) + assert client = Map.fetch!(socket.assigns, :client) + + assert client.external_id == attrs["external_id"] + assert client.public_key == attrs["public_key"] + assert client.last_seen_user_agent == context.user_agent + assert client.last_seen_remote_ip.address == context.remote_ip assert client.last_seen_remote_ip_location_region == "Ukraine" assert client.last_seen_remote_ip_location_city == "Kyiv" assert client.last_seen_remote_ip_location_lat == 50.4333 @@ -64,32 +106,41 @@ defmodule API.Client.SocketTest do end test "propagates trace context" do - subject = Fixtures.Auth.create_subject() - {:ok, token} = Auth.create_session_token_from_subject(subject) + context = Fixtures.Auth.build_context(type: :client) + {_token, encoded_token} = Fixtures.Auth.create_and_encode_token(context: context) span_ctx = OpenTelemetry.Tracer.start_span("test") OpenTelemetry.Tracer.set_current_span(span_ctx) - attrs = connect_attrs(token: token) + attrs = connect_attrs(token: encoded_token) trace_context_headers = [ {"traceparent", "00-a1bf53221e0be8000000000000000002-f316927eb144aa62-01"} ] - connect_info = %{connect_info(subject) | trace_context_headers: trace_context_headers} + connect_info = %{connect_info(context) | trace_context_headers: trace_context_headers} assert {:ok, _socket} = connect(Socket, attrs, connect_info: connect_info) assert span_ctx != OpenTelemetry.Tracer.current_span_ctx() end test "updates existing client" do - subject = Fixtures.Auth.create_subject() - existing_client = Fixtures.Clients.create_client(subject: subject) - {:ok, token} = Auth.create_session_token_from_subject(subject) + account = Fixtures.Accounts.create_account() + context = Fixtures.Auth.build_context(type: :client) + identity = Fixtures.Auth.create_identity(account: account) - attrs = connect_attrs(token: token, external_id: existing_client.external_id) + {_token, encoded_token} = + Fixtures.Auth.create_and_encode_token( + account: account, + identity: identity, + context: context + ) - assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(subject)) + existing_client = Fixtures.Clients.create_client(account: account, identity: identity) + + attrs = connect_attrs(token: encoded_token, external_id: existing_client.external_id) + + assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info(context)) assert client = Repo.one(Domain.Clients.Client) assert client.id == socket.assigns.client.id assert client.last_seen_remote_ip_location_region == "Ukraine" @@ -99,13 +150,22 @@ defmodule API.Client.SocketTest do end test "uses region code to put default coordinates" do - subject = Fixtures.Auth.create_subject() - existing_client = Fixtures.Clients.create_client(subject: subject) - {:ok, token} = Auth.create_session_token_from_subject(subject) + account = Fixtures.Accounts.create_account() + context = Fixtures.Auth.build_context(type: :client) + identity = Fixtures.Auth.create_identity(account: account) - attrs = connect_attrs(token: token, external_id: existing_client.external_id) + {_token, encoded_token} = + Fixtures.Auth.create_and_encode_token( + account: account, + identity: identity, + context: context + ) - connect_info = %{connect_info(subject) | x_headers: [{"x-geo-location-region", "UA"}]} + existing_client = Fixtures.Clients.create_client(account: account, identity: identity) + + attrs = connect_attrs(token: encoded_token, external_id: existing_client.external_id) + + connect_info = %{connect_info(context) | x_headers: [{"x-geo-location-region", "UA"}]} assert {:ok, socket} = connect(Socket, attrs, connect_info: connect_info) assert client = Repo.one(Domain.Clients.Client) @@ -126,10 +186,10 @@ defmodule API.Client.SocketTest do end end - defp connect_info(subject) do + defp connect_info(context) do %{ - user_agent: subject.context.user_agent, - peer_data: %{address: subject.context.remote_ip}, + user_agent: context.user_agent, + peer_data: %{address: context.remote_ip}, x_headers: @geo_headers, trace_context_headers: [] } diff --git a/elixir/apps/domain/lib/domain/accounts.ex b/elixir/apps/domain/lib/domain/accounts.ex index b058fa9be..40a72a317 100644 --- a/elixir/apps/domain/lib/domain/accounts.ex +++ b/elixir/apps/domain/lib/domain/accounts.ex @@ -40,6 +40,9 @@ defmodule Domain.Accounts do end end + def fetch_account_by_id_or_slug(nil), do: {:error, :not_found} + def fetch_account_by_id_or_slug(""), do: {:error, :not_found} + def fetch_account_by_id_or_slug(id_or_slug) do if Validator.valid_uuid?(id_or_slug) do Account.Query.by_id(id_or_slug) diff --git a/elixir/apps/domain/lib/domain/accounts/account.ex b/elixir/apps/domain/lib/domain/accounts/account.ex index 0227f839c..a2da2433c 100644 --- a/elixir/apps/domain/lib/domain/accounts/account.ex +++ b/elixir/apps/domain/lib/domain/accounts/account.ex @@ -33,6 +33,8 @@ defmodule Domain.Accounts.Account do has_many :relay_groups, Domain.Relays.Group, where: [deleted_at: nil] has_many :relay_tokens, Domain.Relays.Token, where: [deleted_at: nil] + has_many :tokens, Domain.Tokens.Token, where: [deleted_at: nil] + timestamps() end end diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index 7364666a2..8dc945647 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -309,8 +309,8 @@ defmodule Domain.Actors do |> Repo.fetch_and_update( with: fn actor -> if actor.type != :account_admin_user or other_enabled_admins_exist?(actor) do - :ok = Auth.delete_actor_identities(actor) - :ok = Clients.delete_actor_clients(actor) + :ok = Auth.delete_actor_identities(actor, subject) + :ok = Clients.delete_actor_clients(actor, subject) Actor.Changeset.delete_actor(actor) else diff --git a/elixir/apps/domain/lib/domain/application.ex b/elixir/apps/domain/lib/domain/application.ex index e61f3a665..9608dfa46 100644 --- a/elixir/apps/domain/lib/domain/application.ex +++ b/elixir/apps/domain/lib/domain/application.ex @@ -32,6 +32,7 @@ defmodule Domain.Application do Domain.Cluster, # Application + Domain.Tokens, Domain.Auth, Domain.Relays, Domain.Gateways, diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index 46c66efc6..761aa712f 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -1,16 +1,90 @@ defmodule Domain.Auth do + @doc """ + This module is the core of our security, it is designed to have multiple layers of + protection and provide guidance for the developers to avoid common security pitfalls. + + ## Authentication + + Authentication is split into two core components: + + 1. *Sign In* - exchange of a secret (IdP ID token or username/password) for our internal token. + + This token is stored in the database (see `Domain.Tokens` module) and then encoded to be + stored in browser session or on mobile clients. For more details see "Tokens" section below. + + 2. Authentication - verification of the token and extraction of the subject from it. + + ## Authorization and Subject + + Authorization is a domain concern because it's tightly coupled with the business logic + and allows better control over the access to the data. Plus makes it more secure iterating + faster on the UI/UX without risking to compromise security. + + Every function directly or indirectly called by the end user MUST have a Subject + as last or second to last argument, implementation of the functions MUST use + it's own context `Authorizer` module (that implements behaviour `Domain.Auth.Authorizer`) + to filter the data based on the account and permissions of the subject. + + As an extra measure, whenever a function performs an action on an object that is not + further re-queried using the `for_subject/1` the implementation MUST check that the subject + has access to given object. It can be done by one of `ensure_has_access_to?/2` functions + added to domain contexts responsible for the given schema, eg. `Domain.Accounts.ensure_has_access_to/2`. + + Only exception is the authentication flow where user can not contain the subject yet, + but such queries MUST be filtered by the `account_id` and use indexes to prevent + simple DDoS attacks. + + ## Tokens + + ### Color Coding + + The tokens are color coded using a `type` field, which means that a token issued for a browser session + can not be used for client calls and vice versa. Type of the token also limits permissions that will + be later added to the subject. + + You can find all the token types in enum value of `type` field in `Domain.Tokens.Token` schema. + + ### Secure client exchange + + The tokens consists of two parts: client-supplied nonce (typically 32-byte hex-encoded string) and + server-generated fragment. + + The server-generated fragment is additionally signed using `Phoenix.Token` to prevent tampering with it + and make sure that database lookups won't be made for invalid tokens. See `Domain.Tokens.encode_fragment!/1` for + more details. + + ### Expiration + + Token expiration depends on the context in which it can be used and is limited by + `@max_session_duration_hours` to prevent extremely long-lived tokens for + `clients` and `browsers`. For more details see `token_expires_at/3`. + + ## Identity Providers + + You can find all the IdP adapters in `Domain.Auth.Adapters` module. + """ use Supervisor - alias Domain.{Repo, Config, Validator} - alias Domain.{Accounts, Actors} - alias Domain.Auth.{Authorizer, Subject, Context, Permission, Roles, Role, Identity} + alias Domain.{Repo, Validator} + alias Domain.{Accounts, Actors, Tokens} + alias Domain.Auth.{Authorizer, Subject, Context, Permission, Roles, Role} alias Domain.Auth.{Adapters, Provider} + alias Domain.Auth.Identity - @default_session_duration_hours %{ - account_admin_user: 24 * 7 - 1, - account_user: 24 * 7, - service_account: 20 * 365 * 24 * 7 - } + # This session duration is used when IdP doesn't return the token expiration date, + # or no IdP is used (eg. sign in via magic link or userpass). + @default_session_duration_hours [ + browser: [ + account_admin_user: 10, + account_user: 10 + ], + client: [ + account_admin_user: 24 * 7, + account_user: 24 * 7 + ] + ] + # We don't want to allow extremely long-lived sessions for clients and browsers + # even if IdP returns them. @max_session_duration_hours @default_session_duration_hours def start_link(opts) do @@ -53,6 +127,17 @@ defmodule Domain.Auth do end end + # used to during auth flow in the UI where Subject doesn't exist yet + def fetch_active_provider_by_id(id) do + if Validator.valid_uuid?(id) do + Provider.Query.by_id(id) + |> Provider.Query.not_disabled() + |> Repo.fetch() + else + {:error, :not_found} + end + end + @doc """ This functions allows to fetch singleton providers like `email` or `token`. """ @@ -75,55 +160,15 @@ defmodule Domain.Auth do end end - def fetch_provider_by_id(id) do - if Validator.valid_uuid?(id) do - Provider.Query.by_id(id) - |> Repo.fetch() - else - {:error, :not_found} - end - end - - def fetch_active_provider_by_id(id, %Subject{} = subject, opts \\ []) do - with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()), - true <- Validator.valid_uuid?(id) do - {preload, _opts} = Keyword.pop(opts, :preload, []) - - Provider.Query.by_id(id) - |> Provider.Query.not_disabled() + def list_providers(%Subject{} = subject) do + with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do + Provider.Query.not_deleted() |> Authorizer.for_subject(Provider, subject) - |> Repo.fetch() - |> case do - {:ok, provider} -> - {:ok, Repo.preload(provider, preload)} - - {:error, reason} -> - {:error, reason} - end - else - false -> {:error, :not_found} - other -> other - end - end - - def fetch_active_provider_by_id(id) do - if Validator.valid_uuid?(id) do - Provider.Query.by_id(id) - |> Provider.Query.not_disabled() - |> Repo.fetch() - else - {:error, :not_found} - end - end - - def list_providers_for_account(%Accounts.Account{} = account, %Subject{} = subject) do - with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()), - :ok <- Accounts.ensure_has_access_to(subject, account) do - Provider.Query.by_account_id(account.id) |> Repo.list() end end + # used to build list of auth options for the UI def list_active_providers_for_account(%Accounts.Account{} = account) do Provider.Query.by_account_id(account.id) |> Provider.Query.not_disabled() @@ -165,6 +210,8 @@ defmodule Domain.Auth do end end + # used for testing and seeding the database + @doc false def create_provider(%Accounts.Account{} = account, attrs) do changeset = Provider.Changeset.create(account, attrs) @@ -181,63 +228,50 @@ defmodule Domain.Auth do end def update_provider(%Provider{} = provider, attrs, %Subject{} = subject) do - with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do - Provider.Query.by_id(provider.id) - |> Authorizer.for_subject(Provider, subject) - |> Repo.fetch_and_update( - with: fn provider -> - Provider.Changeset.update(provider, attrs) - |> Adapters.provider_changeset() - end - ) - end + mutate_provider(provider, subject, fn provider -> + Provider.Changeset.update(provider, attrs) + |> Adapters.provider_changeset() + end) end def disable_provider(%Provider{} = provider, %Subject{} = subject) do - with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do - Provider.Query.by_id(provider.id) - |> Authorizer.for_subject(Provider, subject) - |> Repo.fetch_and_update( - with: fn provider -> - if other_active_providers_exist?(provider) do - Provider.Changeset.disable_provider(provider) - else - :cant_disable_the_last_provider - end - end - ) - end + mutate_provider(provider, subject, fn provider -> + if other_active_providers_exist?(provider) do + Provider.Changeset.disable_provider(provider) + else + :cant_disable_the_last_provider + end + end) end def enable_provider(%Provider{} = provider, %Subject{} = subject) do - with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do - Provider.Query.by_id(provider.id) - |> Authorizer.for_subject(Provider, subject) - |> Repo.fetch_and_update(with: &Provider.Changeset.enable_provider/1) - end + mutate_provider(provider, subject, &Provider.Changeset.enable_provider/1) end def delete_provider(%Provider{} = provider, %Subject{} = subject) do + provider + |> mutate_provider(subject, fn provider -> + if other_active_providers_exist?(provider) do + Provider.Changeset.delete_provider(provider) + else + :cant_delete_the_last_provider + end + end) + |> case do + {:ok, provider} -> + Adapters.ensure_deprovisioned(provider) + + {:error, reason} -> + {:error, reason} + end + end + + defp mutate_provider(%Provider{} = provider, %Subject{} = subject, callback) + when is_function(callback, 1) do with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do Provider.Query.by_id(provider.id) |> Authorizer.for_subject(Provider, subject) - |> Repo.fetch_and_update( - with: fn provider -> - if other_active_providers_exist?(provider) do - provider - |> Provider.Changeset.delete_provider() - else - :cant_delete_the_last_provider - end - end - ) - |> case do - {:ok, provider} -> - Adapters.ensure_deprovisioned(provider) - - {:error, reason} -> - {:error, reason} - end + |> Repo.fetch_and_update(with: callback) end end @@ -256,28 +290,44 @@ defmodule Domain.Auth do # Identities - def fetch_identity_by_id(id, %Subject{} = subject) do - with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()) do - Identity.Query.by_id(id) - |> Authorizer.for_subject(Identity, subject) - |> Repo.fetch() + # used during magic link auth flow + def fetch_active_identity_by_provider_and_identifier( + %Provider{adapter: :email} = provider, + provider_identifier, + opts \\ [] + ) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + Identity.Query.not_disabled() + |> Identity.Query.by_provider_id(provider.id) + |> Identity.Query.by_account_id(provider.account_id) + |> Identity.Query.by_provider_identifier(provider_identifier) + |> Repo.fetch() + |> case do + {:ok, identity} -> + {:ok, Repo.preload(identity, preload)} + + {:error, reason} -> + {:error, reason} end end - def fetch_active_identity_by_id(id) do + defp fetch_active_identity_by_id(id) do Identity.Query.by_id(id) |> Identity.Query.not_disabled() |> Repo.fetch() end - def fetch_identity_by_id(id) do - Identity.Query.by_id(id) - |> Repo.fetch() - end - - def fetch_identity_by_id!(id) do - Identity.Query.by_id(id) - |> Repo.fetch!() + def fetch_identity_by_id(id, %Subject{} = subject) do + with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()), + true <- Validator.valid_uuid?(id) do + Identity.Query.by_id(id) + |> Authorizer.for_subject(Identity, subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end end def fetch_identities_count_grouped_by_provider_id(%Subject{} = subject) do @@ -300,6 +350,7 @@ defmodule Domain.Auth do Identity.Sync.sync_provider_identities_multi(provider, attrs_list) end + # used by IdP adapters def upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, attrs) do Identity.Changeset.create_identity(actor, provider, attrs) |> Adapters.identity_changeset(provider) @@ -307,14 +358,7 @@ defmodule Domain.Auth do conflict_target: {:unsafe_fragment, ~s/(account_id, provider_id, provider_identifier) WHERE deleted_at IS NULL/}, - on_conflict: - {:replace, - [ - :provider_state, - :last_seen_user_agent, - :last_seen_remote_ip, - :last_seen_at - ]}, + on_conflict: {:replace, [:provider_state]}, returning: true ) end @@ -335,7 +379,12 @@ defmodule Domain.Auth do end end - def create_identity(%Actors.Actor{} = actor, %Provider{} = provider, attrs) do + # used during sign up flow + def create_identity( + %Actors.Actor{account_id: account_id} = actor, + %Provider{account_id: account_id} = provider, + attrs + ) do Identity.Changeset.create_identity(actor, provider, attrs) |> Adapters.identity_changeset(provider) |> Repo.insert() @@ -395,12 +444,16 @@ defmodule Domain.Auth do end end - def delete_actor_identities(%Actors.Actor{} = actor) do - {_count, nil} = - Identity.Query.by_actor_id(actor.id) - |> Repo.update_all(set: [deleted_at: DateTime.utc_now(), provider_state: %{}]) + def delete_actor_identities(%Actors.Actor{} = actor, %Subject{} = subject) do + with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()) do + {_count, nil} = + Identity.Query.by_actor_id(actor.id) + |> Identity.Query.by_account_id(actor.account_id) + |> Authorizer.for_subject(Identity, subject) + |> Repo.update_all(set: [deleted_at: DateTime.utc_now(), provider_state: %{}]) - :ok + :ok + end end def identity_disabled?(%{disabled_at: nil}), do: false @@ -411,69 +464,206 @@ defmodule Domain.Auth do # Sign Up / In / Off - def sign_in(%Provider{} = provider, id_or_provider_identifier, secret, user_agent, remote_ip) do + @doc """ + Sign In is an exchange of a secret (IdP token or username/password) for a token tied to it's original context. + """ + def sign_in( + %Provider{disabled_at: disabled_at}, + _id_or_provider_identifier, + _token_nonce, + _secret, + %Context{} + ) + when not is_nil(disabled_at) do + {:error, :unauthorized} + end + + def sign_in( + %Provider{deleted_at: deleted_at}, + _id_or_provider_identifier, + _token_nonce, + _secret, + %Context{} + ) + when not is_nil(deleted_at) do + {:error, :unauthorized} + end + + def sign_in( + %Provider{} = provider, + id_or_provider_identifier, + token_nonce, + secret, + %Context{} = context + ) do identity_queryable = Identity.Query.not_disabled() + |> Identity.Query.by_account_id(provider.account_id) |> Identity.Query.by_provider_id(provider.id) |> Identity.Query.by_id_or_provider_identifier(id_or_provider_identifier) with {:ok, identity} <- Repo.fetch(identity_queryable), - {:ok, identity, expires_at} <- Adapters.verify_secret(provider, identity, secret) do - context = %Context{remote_ip: remote_ip, user_agent: user_agent} - {:ok, build_subject(identity, expires_at, context)} + {:ok, identity, expires_at} <- Adapters.verify_secret(provider, identity, secret), + identity = Repo.preload(identity, :actor), + {:ok, token} <- create_token(identity, context, token_nonce, expires_at) do + {:ok, identity, Tokens.encode_fragment!(token)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid_secret} -> {:error, :unauthorized} {:error, :expired_secret} -> {:error, :unauthorized} + {:error, %Ecto.Changeset{}} -> {:error, :malformed_request} end end - def sign_in(%Provider{} = provider, payload, user_agent, remote_ip) do - with {:ok, identity, expires_at} <- Adapters.verify_and_update_identity(provider, payload) do - context = %Context{remote_ip: remote_ip, user_agent: user_agent} - {:ok, build_subject(identity, expires_at, context)} + def sign_in(%Provider{disabled_at: disabled_at}, _token_nonce, _payload, %Context{}) + when not is_nil(disabled_at) do + {:error, :unauthorized} + end + + def sign_in(%Provider{deleted_at: deleted_at}, _token_nonce, _payload, %Context{}) + when not is_nil(deleted_at) do + {:error, :unauthorized} + end + + def sign_in(%Provider{} = provider, token_nonce, payload, %Context{} = context) do + with {:ok, identity, expires_at} <- Adapters.verify_and_update_identity(provider, payload), + identity = Repo.preload(identity, :actor), + {:ok, token} <- create_token(identity, context, token_nonce, expires_at) do + {:ok, identity, Tokens.encode_fragment!(token)} else {:error, :not_found} -> {:error, :unauthorized} {:error, :invalid} -> {:error, :unauthorized} {:error, :expired} -> {:error, :unauthorized} + {:error, %Ecto.Changeset{}} -> {:error, :malformed_request} end end - def sign_in(token, %Context{} = context) when is_binary(token) do - with {:ok, identity, expires_at} <- verify_token(token, context.user_agent, context.remote_ip) do - {:ok, build_subject(identity, expires_at, context)} - else - {:error, :not_found} -> {:error, :unauthorized} - {:error, :invalid_token} -> {:error, :unauthorized} - {:error, :expired_token} -> {:error, :unauthorized} - {:error, :unauthorized_browser} -> {:error, :unauthorized} - end - end - - def fetch_identity_by_provider_and_identifier( - %Provider{} = provider, - provider_identifier, - opts \\ [] - ) do - {preload, _opts} = Keyword.pop(opts, :preload, []) - - Identity.Query.by_provider_id(provider.id) - |> Identity.Query.by_provider_identifier(provider_identifier) - |> Repo.fetch() - |> case do - {:ok, identity} -> - {:ok, Repo.preload(identity, preload)} - - {:error, reason} -> - {:error, reason} - end - end - + # used in tests and seeds @doc false - def build_subject(%Identity{} = identity, expires_at, context) do + def create_token(identity, %{type: type} = context, nonce, expires_at) + when type in [:browser, :client] do + identity = Repo.preload(identity, :actor) + expires_at = token_expires_at(identity.actor, context, expires_at) + + Tokens.create_token(%{ + type: type, + secret_nonce: nonce, + secret_fragment: Domain.Crypto.random_token(32), + account_id: identity.account_id, + identity_id: identity.id, + expires_at: expires_at, + created_by_user_agent: context.user_agent, + created_by_remote_ip: context.remote_ip + }) + end + + # default expiration is used when IdP/adapter doesn't set the expiration date + defp token_expires_at(%Actors.Actor{} = actor, %Context{} = context, nil) do + default_session_duration_hours = + @default_session_duration_hours + |> Keyword.fetch!(context.type) + |> Keyword.fetch!(actor.type) + + DateTime.utc_now() |> DateTime.add(default_session_duration_hours, :hour) + end + + # For client tokens we extend the expiration to the default one + # for the sake of user experience, because: + # + # - some of the IdPs don't allow to refresh the token without user interaction; + # - some of the IdPs have short-lived hardcoded tokens + # + defp token_expires_at(%Actors.Actor{} = actor, %Context{type: :client}, _expires_at) do + default_session_duration_hours = + @default_session_duration_hours + |> Keyword.fetch!(:client) + |> Keyword.fetch!(actor.type) + + DateTime.utc_now() |> DateTime.add(default_session_duration_hours, :hour) + end + + # when IdP sets the expiration we ensure it's not longer than the default session duration + # to prevent extremely long-lived browser sessions + defp token_expires_at(%Actors.Actor{} = actor, %Context{type: :browser}, expires_at) do + max_session_duration_hours = + @max_session_duration_hours + |> Keyword.fetch!(:browser) + |> Keyword.fetch!(actor.type) + + max_expires_at = DateTime.utc_now() |> DateTime.add(max_session_duration_hours, :hour) + Enum.min([expires_at, max_expires_at], DateTime) + end + + @doc """ + Revokes the Firezone token used by the given subject and, + if IdP was used for Sign In, revokes the IdP token too by redirecting user to IdP logout endpoint. + """ + def sign_out(%Subject{} = subject, redirect_url) do + {:ok, _count} = Tokens.delete_token_by_id(subject.token_id) + identity = Repo.preload(subject.identity, :provider) + Adapters.sign_out(identity.provider, identity, redirect_url) + end + + # Tokens + + def create_service_account_token( + %Provider{adapter: :token} = provider, + %Identity{} = identity, + %Subject{} = subject + ) do + {:ok, expires_at, 0} = DateTime.from_iso8601(identity.provider_state["expires_at"]) + + {:ok, token} = + Tokens.create_token( + %{ + type: :client, + secret_fragment: Domain.Crypto.random_token(32), + account_id: provider.account_id, + identity_id: identity.id, + expires_at: expires_at, + created_by_user_agent: subject.context.user_agent, + created_by_remote_ip: subject.context.remote_ip + }, + subject + ) + + {:ok, Tokens.encode_fragment!(token)} + end + + # Authentication + + def authenticate(encoded_token, %Context{} = context) + when is_binary(encoded_token) do + with {:ok, token} <- Tokens.use_token(encoded_token, context), + :ok <- maybe_enforce_token_context(token, context), + {:ok, identity} <- fetch_active_identity_by_id(token.identity_id) do + {:ok, build_subject(token, identity, context)} + else + {:error, :invalid_or_expired_token} -> {:error, :unauthorized} + {:error, :invalid_remote_ip} -> {:error, :unauthorized} + {:error, :invalid_user_agent} -> {:error, :unauthorized} + {:error, :not_found} -> {:error, :unauthorized} + end + end + + defp maybe_enforce_token_context(%Tokens.Token{} = token, %Context{type: :browser} = context) do + cond do + token.created_by_remote_ip.address != context.remote_ip -> {:error, :invalid_remote_ip} + token.created_by_user_agent != context.user_agent -> {:error, :invalid_user_agent} + true -> :ok + end + end + + defp maybe_enforce_token_context(%Tokens.Token{}, %Context{}) do + :ok + end + + # used in tests and seeds + @doc false + def build_subject(%Tokens.Token{} = token, %Identity{} = identity, %Context{} = context) do identity = identity - |> Identity.Changeset.sign_in_identity(context) + |> Identity.Changeset.track_identity(context) |> Repo.update!() identity_with_preloads = Repo.preload(identity, [:account, :actor]) @@ -484,165 +674,12 @@ defmodule Domain.Auth do actor: identity_with_preloads.actor, permissions: permissions, account: identity_with_preloads.account, - expires_at: build_subject_expires_at(identity_with_preloads.actor, expires_at), - context: context + expires_at: token.expires_at, + context: context, + token_id: token.id } end - defp build_subject_expires_at(%Actors.Actor{} = actor, expires_at) do - now = DateTime.utc_now() - - default_session_duration_hours = Map.fetch!(@default_session_duration_hours, actor.type) - expires_at = expires_at || DateTime.add(now, default_session_duration_hours, :hour) - - max_session_duration_hours = Map.fetch!(@max_session_duration_hours, actor.type) - max_expires_at = DateTime.add(now, max_session_duration_hours, :hour) - - Enum.min([expires_at, max_expires_at], DateTime) - end - - def sign_out(%Identity{} = identity, redirect_url) do - identity = Repo.preload(identity, :provider) - Adapters.sign_out(identity.provider, identity, redirect_url) - end - - # Session - - @doc """ - This token is used to authenticate the user in the Portal UI and should be saved in user session. - """ - def create_session_token_from_subject(%Subject{} = subject) do - # TODO: we want to show all sessions in a UI so persist them to DB - payload = session_token_payload(subject) - sign_token(payload, subject.expires_at) - end - - @doc """ - This token is used to authenticate the client and should be used in the Client WebSocket API. - """ - def create_client_token_from_subject(%Subject{} = subject) do - # TODO: we want to show all sessions in a UI so persist them to DB - payload = client_token_payload(subject) - sign_token(payload, subject.expires_at) - end - - @doc """ - This token is used to authenticate the service account and should be used for REST API requests. - """ - def create_access_token_for_identity(%Identity{} = identity) do - payload = access_token_payload(identity) - {:ok, expires_at, 0} = DateTime.from_iso8601(identity.provider_state["expires_at"]) - sign_token(payload, expires_at) - end - - defp sign_token(payload, expires_at) do - config = fetch_config!() - key_base = Keyword.fetch!(config, :key_base) - salt = Keyword.fetch!(config, :salt) - max_age = DateTime.diff(expires_at, DateTime.utc_now(), :second) - {:ok, Plug.Crypto.sign(key_base, salt, payload, max_age: max_age)} - end - - def fetch_session_token_expires_at(token, opts \\ []) do - config = fetch_config!() - key_base = Keyword.fetch!(config, :key_base) - salt = Keyword.fetch!(config, :salt) - - iterations = Keyword.get(opts, :key_iterations, 1000) - length = Keyword.get(opts, :key_length, 32) - digest = Keyword.get(opts, :key_digest, :sha256) - cache = Keyword.get(opts, :cache, Plug.Crypto.Keys) - secret = Plug.Crypto.KeyGenerator.generate(key_base, salt, iterations, length, digest, cache) - - with {:ok, message} <- Plug.Crypto.MessageVerifier.verify(token, secret) do - {_data, signed, max_age} = Plug.Crypto.non_executable_binary_to_term(message) - {:ok, datetime} = DateTime.from_unix(signed + trunc(max_age * 1000), :millisecond) - {:ok, datetime} - else - :error -> {:error, :invalid_token} - end - end - - defp session_context_payload(remote_ip, user_agent) - when is_tuple(remote_ip) and is_binary(user_agent) do - :crypto.hash(:sha256, :erlang.term_to_binary({remote_ip, user_agent})) - end - - defp verify_token(token, user_agent, remote_ip) do - config = fetch_config!() - key_base = Keyword.fetch!(config, :key_base) - salt = Keyword.fetch!(config, :salt) - - case Plug.Crypto.verify(key_base, salt, token) do - {:ok, payload} -> verify_token_payload(token, payload, user_agent, remote_ip) - {:error, :invalid} -> {:error, :invalid_token} - {:error, :expired} -> {:error, :expired_token} - end - end - - defp verify_token_payload( - _token, - {:identity, identity_id, secret, :ignore}, - _user_agent, - _remote_ip - ) do - with {:ok, identity} <- fetch_active_identity_by_id(identity_id), - {:ok, provider} <- fetch_active_provider_by_id(identity.provider_id), - {:ok, identity, expires_at} <- - Adapters.verify_secret(provider, identity, secret) do - {:ok, identity, expires_at} - else - {:error, :invalid_secret} -> {:error, :invalid_token} - {:error, :expired_secret} -> {:error, :expired_token} - {:error, :not_found} -> {:error, :not_found} - end - end - - defp verify_token_payload( - token, - {:identity, identity_id, :ignore}, - _user_agent, - _remote_ip - ) do - with {:ok, identity} <- fetch_active_identity_by_id(identity_id), - {:ok, expires_at} <- fetch_session_token_expires_at(token) do - {:ok, identity, expires_at} - end - end - - defp verify_token_payload( - token, - {:identity, identity_id, _context_payload}, - _user_agent, - _remote_ip - ) do - with {:ok, identity} <- fetch_active_identity_by_id(identity_id), - # XXX: Don't pin tokens to remote_ip and user_agent -- use device external_id instead? - # true <- context_payload == session_context_payload(remote_ip, user_agent), - {:ok, expires_at} <- fetch_session_token_expires_at(token) do - {:ok, identity, expires_at} - else - false -> {:error, :unauthorized_browser} - other -> other - end - end - - defp session_token_payload(%Subject{identity: %Identity{} = identity, context: context}) do - {:identity, identity.id, session_context_payload(context.remote_ip, context.user_agent)} - end - - defp client_token_payload(%Subject{identity: %Identity{} = identity}) do - {:identity, identity.id, :ignore} - end - - defp access_token_payload(%Identity{} = identity) do - {:identity, identity.id, identity.provider_virtual_state.changes.secret, :ignore} - end - - defp fetch_config! do - Config.fetch_env!(:domain, __MODULE__) - end - # Permissions def has_permission?( @@ -658,10 +695,6 @@ defmodule Domain.Auth do end) end - def has_permissions?(%Subject{} = subject, required_permissions) do - ensure_has_permissions(subject, required_permissions) == :ok - end - def fetch_type_permissions!(%Role{} = type), do: type.permissions @@ -682,15 +715,33 @@ defmodule Domain.Auth do end def ensure_has_permissions(%Subject{} = subject, required_permissions) do - required_permissions - |> List.wrap() - |> Enum.reject(fn required_permission -> - has_permission?(subject, required_permission) - end) - |> Enum.uniq() - |> case do - [] -> :ok - missing_permissions -> {:error, {:unauthorized, missing_permissions: missing_permissions}} + with :ok <- ensure_permissions_are_not_expired(subject) do + required_permissions + |> List.wrap() + |> Enum.reject(fn required_permission -> + has_permission?(subject, required_permission) + end) + |> Enum.uniq() + |> case do + [] -> + :ok + + missing_permissions -> + {:error, + {:unauthorized, reason: :missing_permissions, missing_permissions: missing_permissions}} + end + end + end + + defp ensure_permissions_are_not_expired(%Subject{expires_at: nil}) do + :ok + end + + defp ensure_permissions_are_not_expired(%Subject{expires_at: expires_at}) do + if DateTime.after?(expires_at, DateTime.utc_now()) do + :ok + else + {:error, {:unauthorized, reason: :subject_expired}} end end diff --git a/elixir/apps/domain/lib/domain/auth/adapter.ex b/elixir/apps/domain/lib/domain/auth/adapter.ex index c6151961c..ae064c85c 100644 --- a/elixir/apps/domain/lib/domain/auth/adapter.ex +++ b/elixir/apps/domain/lib/domain/auth/adapter.ex @@ -7,6 +7,10 @@ defmodule Domain.Auth.Adapter do The `:custom` is a special key which means that the IdP adapter implements its own provisioning logic (eg. API integration), so it should be rendered in the UI on pre-provider basis. + + Setting it to `:custom` will also allow running recurrent jobs for the provider, + for more details see `Domain.Auth.list_providers_pending_token_refresh_by_adapter/1` + and `Domain.Auth.list_providers_pending_sync_by_adapter/1`. """ @type provisioner :: :manual | :just_in_time | :custom diff --git a/elixir/apps/domain/lib/domain/auth/adapters/email.ex b/elixir/apps/domain/lib/domain/auth/adapters/email.ex index 53c8a976f..57fbfd6b7 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/email.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/email.ex @@ -78,7 +78,7 @@ defmodule Domain.Auth.Adapters.Email do { %{ "sign_in_token_salt" => salt, - "sign_in_token_hash" => Domain.Crypto.hash(sign_in_token <> salt), + "sign_in_token_hash" => Domain.Crypto.hash(:argon2, sign_in_token <> salt), "sign_in_token_created_at" => DateTime.utc_now() }, %{ @@ -127,7 +127,7 @@ defmodule Domain.Auth.Adapters.Email do sign_in_token_expired?(sign_in_token_created_at) -> :expired_secret - not Domain.Crypto.equal?(token <> sign_in_token_salt, sign_in_token_hash) -> + not Domain.Crypto.equal?(:argon2, token <> sign_in_token_salt, sign_in_token_hash) -> track_failed_attempt!(identity) true -> diff --git a/elixir/apps/domain/lib/domain/auth/adapters/token.ex b/elixir/apps/domain/lib/domain/auth/adapters/token.ex index 68dab5304..532b27ca3 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/token.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/token.ex @@ -39,7 +39,7 @@ defmodule Domain.Auth.Adapters.Token do defp put_hash_and_expiration(changeset) do secret = Domain.Crypto.random_token(32) - secret_hash = Domain.Crypto.hash(secret) + secret_hash = Domain.Crypto.hash(:argon2, secret) data = Map.get(changeset.data, :provider_virtual_state) || %{} attrs = Ecto.Changeset.get_change(changeset, :provider_virtual_state) || %{} @@ -112,7 +112,7 @@ defmodule Domain.Auth.Adapters.Token do sign_in_token_expired?(secret_expires_at) -> :expired_secret - not Domain.Crypto.equal?(secret, secret_hash) -> + not Domain.Crypto.equal?(:argon2, secret, secret_hash) -> :invalid_secret true -> diff --git a/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex b/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex index b0d0a52ec..d071d008f 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex @@ -103,7 +103,7 @@ defmodule Domain.Auth.Adapters.UserPass do is_nil(password_hash) -> :invalid_secret - not Domain.Crypto.equal?(password, password_hash) -> + not Domain.Crypto.equal?(:argon2, password, password_hash) -> :invalid_secret true -> diff --git a/elixir/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex index 39485df97..0da516a14 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/userpass/password/changeset.ex @@ -23,7 +23,7 @@ defmodule Domain.Auth.Adapters.UserPass.Password.Changeset do # |> validate_no_repetitive_characters(:password) # |> validate_no_sequential_characters(:password) # |> validate_no_public_context(:password) - |> put_hash(:password, to: :password_hash) + |> put_hash(:password, :argon2, to: :password_hash) |> validate_required([:password_hash]) end end diff --git a/elixir/apps/domain/lib/domain/auth/context.ex b/elixir/apps/domain/lib/domain/auth/context.ex index c9cf56aa7..17cafeda4 100644 --- a/elixir/apps/domain/lib/domain/auth/context.ex +++ b/elixir/apps/domain/lib/domain/auth/context.ex @@ -6,6 +6,7 @@ defmodule Domain.Auth.Context do the client and IP address used to perform the action. """ @type t :: %__MODULE__{ + type: :browser | :client | :relay | :gateway | :api_client, remote_ip: :inet.ip_address(), remote_ip_location_region: String.t(), remote_ip_location_city: String.t(), @@ -14,7 +15,9 @@ defmodule Domain.Auth.Context do user_agent: String.t() } - defstruct remote_ip: nil, + @enforce_keys [:type, :remote_ip, :user_agent] + defstruct type: nil, + remote_ip: nil, remote_ip_location_region: nil, remote_ip_location_city: nil, remote_ip_location_lat: nil, diff --git a/elixir/apps/domain/lib/domain/auth/identity/changeset.ex b/elixir/apps/domain/lib/domain/auth/identity/changeset.ex index f84bce443..256462c72 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/changeset.ex @@ -74,7 +74,7 @@ defmodule Domain.Auth.Identity.Changeset do |> put_change(:provider_virtual_state, virtual_state) end - def sign_in_identity(identity_or_changeset, context) do + def track_identity(identity_or_changeset, context) do identity_or_changeset |> change() |> put_change(:last_seen_user_agent, context.user_agent) diff --git a/elixir/apps/domain/lib/domain/auth/identity/query.ex b/elixir/apps/domain/lib/domain/auth/identity/query.ex index 4a9c3701b..71fc71fda 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/query.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/query.ex @@ -10,6 +10,16 @@ defmodule Domain.Auth.Identity.Query do |> where([identities: identities], is_nil(identities.deleted_at)) end + def not_disabled(queryable \\ not_deleted()) do + queryable + |> with_assoc(:inner, :actor) + |> where([actor: actor], is_nil(actor.deleted_at)) + |> where([actor: actor], is_nil(actor.disabled_at)) + |> with_assoc(:inner, :provider) + |> where([provider: provider], is_nil(provider.deleted_at)) + |> where([provider: provider], is_nil(provider.disabled_at)) + end + def by_id(queryable \\ not_deleted(), id) def by_id(queryable, {:not, id}) do @@ -31,8 +41,6 @@ defmodule Domain.Auth.Identity.Query do def by_provider_id(queryable \\ not_deleted(), provider_id) do queryable |> where([identities: identities], identities.provider_id == ^provider_id) - |> with_assoc(:inner, :provider) - |> where([provider: provider], is_nil(provider.disabled_at) and is_nil(provider.deleted_at)) end def by_adapter(queryable \\ not_deleted(), adapter) do @@ -70,13 +78,6 @@ defmodule Domain.Auth.Identity.Query do end end - def not_disabled(queryable \\ not_deleted()) do - queryable - |> join(:inner, [identities: identities], actors in assoc(identities, :actor), as: :actors) - |> where([actors: actors], is_nil(actors.deleted_at)) - |> where([actors: actors], is_nil(actors.disabled_at)) - end - def lock(queryable \\ not_deleted()) do lock(queryable, "FOR UPDATE") end diff --git a/elixir/apps/domain/lib/domain/auth/provider/query.ex b/elixir/apps/domain/lib/domain/auth/provider/query.ex index 99b26faa3..4de822fd5 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/query.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/query.ex @@ -10,6 +10,14 @@ defmodule Domain.Auth.Provider.Query do |> where([provider: provider], is_nil(provider.deleted_at)) end + def not_disabled(queryable \\ not_deleted()) do + where(queryable, [provider: provider], is_nil(provider.disabled_at)) + end + + def not_exceeded_attempts(queryable \\ not_deleted()) do + where(queryable, [provider: provider], provider.last_syncs_failed <= 10) + end + def by_id(queryable \\ not_deleted(), id) def by_id(queryable, {:not, id}) do @@ -75,14 +83,6 @@ defmodule Domain.Auth.Provider.Query do where(queryable, [provider: provider], provider.account_id == ^account_id) end - def not_disabled(queryable \\ not_deleted()) do - where(queryable, [provider: provider], is_nil(provider.disabled_at)) - end - - def not_exceeded_attempts(queryable \\ not_deleted()) do - where(queryable, [provider: provider], provider.last_syncs_failed <= 10) - end - def lock(queryable \\ not_deleted()) do lock(queryable, "FOR UPDATE") end diff --git a/elixir/apps/domain/lib/domain/auth/roles.ex b/elixir/apps/domain/lib/domain/auth/roles.ex index 08ccb3980..17fb770ea 100644 --- a/elixir/apps/domain/lib/domain/auth/roles.ex +++ b/elixir/apps/domain/lib/domain/auth/roles.ex @@ -19,7 +19,8 @@ defmodule Domain.Auth.Roles do Domain.Policies.Authorizer, Domain.Relays.Authorizer, Domain.Resources.Authorizer, - Domain.Flows.Authorizer + Domain.Flows.Authorizer, + Domain.Tokens.Authorizer ] end diff --git a/elixir/apps/domain/lib/domain/auth/subject.ex b/elixir/apps/domain/lib/domain/auth/subject.ex index 2f798c081..37b31c7a9 100644 --- a/elixir/apps/domain/lib/domain/auth/subject.ex +++ b/elixir/apps/domain/lib/domain/auth/subject.ex @@ -11,15 +11,17 @@ defmodule Domain.Auth.Subject do actor: actor(), permissions: MapSet.t(permission), account: %Domain.Accounts.Account{}, + token_id: Ecto.UUID.t(), expires_at: DateTime.t(), context: Context.t() } - @enforce_keys [:identity, :actor, :permissions, :account, :context, :expires_at] + @enforce_keys [:identity, :actor, :permissions, :account, :token_id, :expires_at, :context] defstruct identity: nil, actor: nil, permissions: MapSet.new(), account: nil, + token_id: nil, expires_at: nil, - context: %Context{} + context: nil end diff --git a/elixir/apps/domain/lib/domain/clients.ex b/elixir/apps/domain/lib/domain/clients.ex index ec035eb70..5ceb1b341 100644 --- a/elixir/apps/domain/lib/domain/clients.ex +++ b/elixir/apps/domain/lib/domain/clients.ex @@ -204,12 +204,16 @@ defmodule Domain.Clients do end end - def delete_actor_clients(%Actors.Actor{} = actor) do - {_count, nil} = - Client.Query.by_actor_id(actor.id) - |> Repo.update_all(set: [deleted_at: DateTime.utc_now()]) + def delete_actor_clients(%Actors.Actor{} = actor, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_clients_permission()) do + {_count, nil} = + Client.Query.by_actor_id(actor.id) + |> Client.Query.by_account_id(actor.account_id) + |> Authorizer.for_subject(subject) + |> Repo.update_all(set: [deleted_at: DateTime.utc_now()]) - :ok + :ok + end end def authorize_actor_client_management(%Actors.Actor{} = actor, %Auth.Subject{} = subject) do diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 8447c3f93..11531d453 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -80,8 +80,8 @@ defmodule Domain.Config.Definitions do All secrets should be a **base64-encoded string**. """, [ - :auth_token_key_base, - :auth_token_salt, + :tokens_key_base, + :tokens_salt, :relays_auth_token_key_base, :relays_auth_token_salt, :gateways_auth_token_key_base, @@ -195,7 +195,7 @@ defmodule Domain.Config.Definitions do You can see all supported options at https://ninenines.eu/docs/en/cowboy/2.5/manual/cowboy_http/. """ defconfig(:phoenix_http_protocol_options, :map, - default: %{}, + default: %{max_header_value_length: 8192}, dump: &Dumper.keyword/1 ) @@ -357,17 +357,17 @@ defmodule Domain.Config.Definitions do ############################################## @doc """ - Secret which is used to encode and sign auth tokens. + Secret which is used to encode and sign tokens. """ - defconfig(:auth_token_key_base, :string, + defconfig(:tokens_key_base, :string, sensitive: true, changeset: &Domain.Validator.validate_base64/2 ) @doc """ - Salt which is used to encode and sign auth tokens. + Salt which is used to encode and sign tokens. """ - defconfig(:auth_token_salt, :string, + defconfig(:tokens_salt, :string, sensitive: true, changeset: &Domain.Validator.validate_base64/2 ) diff --git a/elixir/apps/domain/lib/domain/crypto.ex b/elixir/apps/domain/lib/domain/crypto.ex index dc78bf93b..912184755 100644 --- a/elixir/apps/domain/lib/domain/crypto.ex +++ b/elixir/apps/domain/lib/domain/crypto.ex @@ -42,6 +42,10 @@ defmodule Domain.Crypto do Base.encode64(binary) end + defp encode_random_token(binary, _length, :hex32) do + Base.hex_encode32(binary) + end + defp encode_random_token(binary, length, :user_friendly) do encode_random_token(binary, length, :url_encode64) |> String.downcase() @@ -61,15 +65,34 @@ defmodule Domain.Crypto do defp replace_ambiguous_characters(<>, acc), do: replace_ambiguous_characters(rest, <>) - def hash(type, value) do - :crypto.hash(type, value) + def hash(:argon2, value) when byte_size(value) > 0 do + Argon2.hash_pwd_salt(value) + end + + def hash(algo, value) when byte_size(value) > 0 do + :crypto.hash(algo, value) |> Base.encode16() |> String.downcase() end - def hash(value), do: Argon2.hash_pwd_salt(value) + @doc """ + Compares two secret and hash in a constant-time avoiding timing attacks. + """ + def equal?(:argon2, secret, hash) when is_nil(secret) or is_nil(hash), + do: Argon2.no_user_verify() - def equal?(token, hash) when is_nil(token) or is_nil(hash), do: Argon2.no_user_verify() - def equal?(token, hash) when token == "" or hash == "", do: Argon2.no_user_verify() - def equal?(token, hash), do: Argon2.verify_pass(token, hash) + def equal?(:argon2, secret, hash) when secret == "" or hash == "", + do: Argon2.no_user_verify() + + def equal?(:argon2, secret, hash), + do: Argon2.verify_pass(secret, hash) + + def equal?(algo, secret, hash) when is_nil(secret) or is_nil(hash), + do: Plug.Crypto.secure_compare(hash(algo, "a"), "b") + + def equal?(algo, secret, hash) when secret == "" or hash == "", + do: Plug.Crypto.secure_compare(hash(algo, "a"), "b") + + def equal?(algo, secret, hash), + do: Plug.Crypto.secure_compare(hash(algo, secret), hash) end diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 5872a9421..c6a3224ed 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -170,7 +170,7 @@ defmodule Domain.Gateways do Token.Query.by_id(id) |> Repo.fetch_and_update( with: fn token -> - if Domain.Crypto.equal?(secret, token.hash) do + if Domain.Crypto.equal?(:argon2, secret, token.hash) do Token.Changeset.use(token) else :not_found diff --git a/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex b/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex index 5a5723adc..a4a1bc9fb 100644 --- a/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex @@ -10,7 +10,8 @@ defmodule Domain.Gateways.Gateway.Changeset do last_seen_remote_ip_location_city last_seen_remote_ip_location_lat last_seen_remote_ip_location_lon]a - @conflict_replace_fields ~w[public_key + @conflict_replace_fields ~w[name + public_key last_seen_user_agent last_seen_remote_ip last_seen_remote_ip_location_region diff --git a/elixir/apps/domain/lib/domain/gateways/token/changeset.ex b/elixir/apps/domain/lib/domain/gateways/token/changeset.ex index 78e6c10f6..e6d1adb8d 100644 --- a/elixir/apps/domain/lib/domain/gateways/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/token/changeset.ex @@ -9,7 +9,7 @@ defmodule Domain.Gateways.Token.Changeset do |> change() |> put_change(:account_id, account.id) |> put_change(:value, Domain.Crypto.random_token(64)) - |> put_hash(:value, to: :hash) + |> put_hash(:value, :argon2, to: :hash) |> assoc_constraint(:group) |> check_constraint(:hash, name: :hash_not_null, message: "can't be blank") |> put_change(:created_by, :identity) diff --git a/elixir/apps/domain/lib/domain/jobs.ex b/elixir/apps/domain/lib/domain/jobs.ex index 2e05d2465..ded6505f9 100644 --- a/elixir/apps/domain/lib/domain/jobs.ex +++ b/elixir/apps/domain/lib/domain/jobs.ex @@ -6,7 +6,7 @@ defmodule Domain.Jobs do use Supervisor def start_link(module) do - Supervisor.start_link(__MODULE__, module, name: __MODULE__) + Supervisor.start_link(__MODULE__, module) end def init(module) do diff --git a/elixir/apps/domain/lib/domain/relays.ex b/elixir/apps/domain/lib/domain/relays.ex index cf33b452b..2b420ead5 100644 --- a/elixir/apps/domain/lib/domain/relays.ex +++ b/elixir/apps/domain/lib/domain/relays.ex @@ -170,7 +170,7 @@ defmodule Domain.Relays do Token.Query.by_id(id) |> Repo.fetch_and_update( with: fn token -> - if Domain.Crypto.equal?(secret, token.hash) do + if Domain.Crypto.equal?(:argon2, secret, token.hash) do Token.Changeset.use(token) else :not_found diff --git a/elixir/apps/domain/lib/domain/relays/token/changeset.ex b/elixir/apps/domain/lib/domain/relays/token/changeset.ex index 67fb693f3..c7c2dfaf4 100644 --- a/elixir/apps/domain/lib/domain/relays/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/relays/token/changeset.ex @@ -8,7 +8,7 @@ defmodule Domain.Relays.Token.Changeset do %Relays.Token{} |> change() |> put_change(:value, Domain.Crypto.random_token(64)) - |> put_hash(:value, to: :hash) + |> put_hash(:value, :argon2, to: :hash) |> assoc_constraint(:group) |> check_constraint(:hash, name: :hash_not_null, message: "can't be blank") |> put_change(:created_by, :system) diff --git a/elixir/apps/domain/lib/domain/tokens.ex b/elixir/apps/domain/lib/domain/tokens.ex new file mode 100644 index 000000000..cded2804c --- /dev/null +++ b/elixir/apps/domain/lib/domain/tokens.ex @@ -0,0 +1,215 @@ +defmodule Domain.Tokens do + use Supervisor + alias Domain.{Repo, Validator} + alias Domain.{Auth, Actors} + alias Domain.Tokens.{Token, Authorizer, Jobs} + require Ecto.Query + + def start_link(_init_arg) do + Supervisor.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + children = [ + {Domain.Jobs, Jobs} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def fetch_token_by_id(id, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_tokens_permission(), + Authorizer.manage_own_tokens_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions), + true <- Validator.valid_uuid?(id) do + Token.Query.by_id(id) + |> Authorizer.for_subject(subject) + |> Repo.fetch() + else + false -> {:error, :not_found} + other -> other + end + end + + def list_tokens_for(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_tokens_permission()) do + Token.Query.by_actor_id(subject.actor.id) + |> list_tokens(subject, []) + end + end + + def list_tokens_for(%Actors.Actor{} = actor, %Auth.Subject{} = subject, opts \\ []) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_tokens_permission()) do + Token.Query.by_actor_id(actor.id) + |> list_tokens(subject, opts) + end + end + + defp list_tokens(queryable, subject, opts) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + {:ok, tokens} = + queryable + |> Authorizer.for_subject(subject) + |> Ecto.Query.order_by([tokens: tokens], desc: tokens.inserted_at, desc: tokens.id) + |> Repo.list() + + {:ok, Repo.preload(tokens, preload)} + end + + def create_token(attrs) do + Token.Changeset.create(attrs) + |> Repo.insert() + end + + def create_token(attrs, %Auth.Subject{} = subject) do + Token.Changeset.create(attrs, subject) + |> Repo.insert() + end + + @doc """ + Update allows to extend the token expiration, which is useful for situations where we can use + IdP API to refresh the ID token and don't want users to go through redirects every hour + (hardcoded token duration for Okta and Google Workspace). + """ + def update_token(%Token{} = token, attrs) do + Token.Query.by_id(token.id) + |> Token.Query.not_expired() + |> Repo.fetch_and_update(with: &Token.Changeset.update(&1, attrs)) + end + + @doc """ + Token `secret` is used to verify that token can be used only by one source and that it's + impossible to impersonate a session by knowing what's inside our database. + + It then additionally signed and encoded using `Plug.Crypto.sign/3` to make sure that + you can't hit our database with requests using a random token id and secret. + """ + def encode_fragment!(%Token{secret_fragment: fragment, type: type} = token) + when not is_nil(fragment) do + body = {token.account_id, token.id, fragment} + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + salt = Keyword.fetch!(config, :salt) + "." <> Plug.Crypto.sign(key_base, salt <> to_string(type), body) + end + + def use_token(encoded_token, %Auth.Context{} = context) do + with {:ok, {account_id, id, nonce, secret}} <- peek_token(encoded_token, context), + queryable = + Token.Query.by_id(id) + |> Token.Query.by_account_id(account_id) + |> Token.Query.by_type(context.type) + |> Token.Query.not_expired(), + {:ok, token} <- Repo.fetch(queryable), + true <- + Domain.Crypto.equal?( + :sha3_256, + nonce <> secret <> token.secret_salt, + token.secret_hash + ) do + Token.Changeset.use(token, context) + |> Repo.update() + else + {:error, :invalid} -> {:error, :invalid_or_expired_token} + {:ok, _token_payload} -> {:error, :invalid_or_expired_token} + {:error, :not_found} -> {:error, :invalid_or_expired_token} + false -> {:error, :invalid_or_expired_token} + _other -> {:error, :invalid_or_expired_token} + end + end + + def peek_token(encoded_token, %Auth.Context{} = context) do + with [nonce, encoded_fragment] <- String.split(encoded_token, ".", parts: 2), + {:ok, {account_id, id, secret}} <- verify_token(encoded_fragment, context) do + {:ok, {account_id, id, nonce, secret}} + end + end + + defp verify_token(encrypted_token, %Auth.Context{} = context) do + config = fetch_config!() + key_base = Keyword.fetch!(config, :key_base) + shared_salt = Keyword.fetch!(config, :salt) + salt = shared_salt <> to_string(context.type) + + Plug.Crypto.verify(key_base, salt, encrypted_token, max_age: :infinity) + end + + def delete_token(%Token{} = token, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_tokens_permission(), + Authorizer.manage_own_tokens_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + Token.Query.by_id(token.id) + |> Authorizer.for_subject(subject) + |> Repo.fetch_and_update(with: &Token.Changeset.delete/1) + |> case do + {:ok, token} -> + Phoenix.PubSub.broadcast(Domain.PubSub, "sessions:#{token.id}", "disconnect") + {:ok, token} + + {:error, reason} -> + {:error, reason} + end + end + end + + def delete_tokens_for(%Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_tokens_permission()) do + Token.Query.by_actor_id(subject.actor.id) + |> Authorizer.for_subject(subject) + |> delete_tokens() + end + end + + def delete_tokens_for(%Actors.Actor{} = actor, %Auth.Subject{} = subject) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_tokens_permission()) do + Token.Query.by_actor_id(actor.id) + |> Authorizer.for_subject(subject) + |> delete_tokens() + end + end + + def delete_expired_tokens do + Token.Query.expired() + |> delete_tokens() + end + + defp delete_tokens(queryable) do + {count, ids} = + queryable + |> Ecto.Query.select([tokens: tokens], tokens.id) + |> Repo.update_all(set: [deleted_at: DateTime.utc_now()]) + + :ok = + Enum.each(ids, fn id -> + # TODO: use Domain.PubSub once it's in the codebase + Phoenix.PubSub.broadcast(Domain.PubSub, "sessions:#{id}", "disconnect") + end) + + {:ok, count} + end + + def delete_token_by_id(token_id) do + if Validator.valid_uuid?(token_id) do + Token.Query.by_id(token_id) + |> delete_tokens() + else + {:ok, 0} + end + end + + defp fetch_config! do + Domain.Config.fetch_env!(:domain, __MODULE__) + end +end diff --git a/elixir/apps/domain/lib/domain/tokens/authorizer.ex b/elixir/apps/domain/lib/domain/tokens/authorizer.ex new file mode 100644 index 000000000..99adb61f4 --- /dev/null +++ b/elixir/apps/domain/lib/domain/tokens/authorizer.ex @@ -0,0 +1,38 @@ +defmodule Domain.Tokens.Authorizer do + use Domain.Auth.Authorizer + alias Domain.Tokens.Token + + def manage_own_tokens_permission, do: build(Token, :manage_own) + def manage_tokens_permission, do: build(Token, :manage) + + @impl Domain.Auth.Authorizer + def list_permissions_for_role(:account_admin_user) do + [ + manage_tokens_permission(), + manage_own_tokens_permission() + ] + end + + def list_permissions_for_role(:account_user) do + [ + manage_own_tokens_permission() + ] + end + + def list_permissions_for_role(_) do + [] + end + + @impl true + def for_subject(queryable, %Subject{} = subject) do + cond do + has_permission?(subject, manage_tokens_permission()) -> + Token.Query.by_account_id(queryable, subject.account.id) + + has_permission?(subject, manage_own_tokens_permission()) -> + queryable + |> Token.Query.by_account_id(subject.account.id) + |> Token.Query.by_actor_id(subject.actor.id) + end + end +end diff --git a/elixir/apps/domain/lib/domain/tokens/jobs.ex b/elixir/apps/domain/lib/domain/tokens/jobs.ex new file mode 100644 index 000000000..e29f69449 --- /dev/null +++ b/elixir/apps/domain/lib/domain/tokens/jobs.ex @@ -0,0 +1,10 @@ +defmodule Domain.Tokens.Jobs do + use Domain.Jobs.Recurrent, otp_app: :domain + alias Domain.Tokens + require Logger + + every minutes(5), :delete_expired_tokens do + {:ok, _count} = Tokens.delete_expired_tokens() + :ok + end +end diff --git a/elixir/apps/domain/lib/domain/tokens/token.ex b/elixir/apps/domain/lib/domain/tokens/token.ex new file mode 100644 index 000000000..bffcbf7d8 --- /dev/null +++ b/elixir/apps/domain/lib/domain/tokens/token.ex @@ -0,0 +1,38 @@ +# TODO: service accounts auth as clients and as API clients? +defmodule Domain.Tokens.Token do + use Domain, :schema + + schema "tokens" do + field :type, Ecto.Enum, values: [:browser, :client, :relay, :gateway, :email, :api_client] + + belongs_to :identity, Domain.Auth.Identity + # belongs_to :relay_group, Domain.Relays.Group + # belongs_to :gateway_group, Domain.Relays.Group + + # we store just hash(nonce+fragment+salt) + field :secret_nonce, :string, virtual: true, redact: true + field :secret_fragment, :string, virtual: true, redact: true + field :secret_salt, :string, redact: true + field :secret_hash, :string, redact: true + + belongs_to :account, Domain.Accounts.Account + + field :last_seen_user_agent, :string + field :last_seen_remote_ip, Domain.Types.IP + field :last_seen_remote_ip_location_region, :string + field :last_seen_remote_ip_location_city, :string + field :last_seen_remote_ip_location_lat, :float + field :last_seen_remote_ip_location_lon, :float + field :last_seen_at, :utc_datetime_usec + + # Maybe this is not needed and they should be in the join tables (eg. relay_group_tokens) + field :created_by, Ecto.Enum, values: ~w[system identity]a + belongs_to :created_by_identity, Domain.Auth.Identity + field :created_by_user_agent, :string + field :created_by_remote_ip, Domain.Types.IP + + field :expires_at, :utc_datetime_usec + field :deleted_at, :utc_datetime_usec + timestamps() + end +end diff --git a/elixir/apps/domain/lib/domain/tokens/token/changeset.ex b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex new file mode 100644 index 000000000..2581e3bfa --- /dev/null +++ b/elixir/apps/domain/lib/domain/tokens/token/changeset.ex @@ -0,0 +1,97 @@ +defmodule Domain.Tokens.Token.Changeset do + use Domain, :changeset + alias Domain.Auth + alias Domain.Tokens.Token + + @required_attrs ~w[ + type + account_id + secret_fragment + created_by_user_agent created_by_remote_ip + expires_at + ]a + + @create_attrs ~w[identity_id secret_nonce]a ++ @required_attrs + @update_attrs ~w[expires_at]a + + def create(attrs) do + %Token{} + |> cast(attrs, @create_attrs) + |> validate_required(@required_attrs) + |> validate_inclusion(:type, [:email, :browser, :client]) + |> changeset() + |> put_change(:created_by, :system) + end + + def create(attrs, %Auth.Subject{} = subject) do + %Token{} + |> cast(attrs, @create_attrs) + |> put_change(:account_id, subject.account.id) + |> validate_required(@required_attrs) + |> validate_inclusion(:type, [:client, :relay, :gateway, :api_client]) + |> changeset() + |> put_change(:created_by, :identity) + |> put_change(:created_by_identity_id, subject.identity.id) + end + + defp changeset(changeset) do + changeset + |> put_change(:secret_salt, Domain.Crypto.random_token(16)) + |> validate_format(:secret_nonce, ~r/^[^\.]{0,128}$/) + |> put_hash(:secret_fragment, :sha3_256, + with_nonce: :secret_nonce, + with_salt: :secret_salt, + to: :secret_hash + ) + |> delete_change(:secret_nonce) + |> validate_datetime(:expires_at, greater_than: DateTime.utc_now()) + |> validate_required(~w[secret_salt secret_hash]a) + |> validate_required_assocs() + |> assoc_constraint(:account) + end + + defp validate_required_assocs(changeset) do + case fetch_field(changeset, :context) do + {_data_or_changes, :browser} -> + changeset + |> validate_required(:identity_id) + |> assoc_constraint(:identity) + + {_data_or_changes, :client} -> + changeset + |> validate_required(:identity_id) + |> assoc_constraint(:identity) + + # TODO: relay, gateway, api_client + + _ -> + changeset + end + end + + def update(%Token{} = token, attrs) do + token + |> cast(attrs, @update_attrs) + |> validate_required(@update_attrs) + |> validate_datetime(:expires_at, greater_than: DateTime.utc_now()) + end + + def use(%Token{} = token, %Auth.Context{} = context) do + token + |> change() + |> put_change(:last_seen_user_agent, context.user_agent) + |> put_change(:last_seen_remote_ip, %Postgrex.INET{address: context.remote_ip}) + |> put_change(:last_seen_remote_ip_location_region, context.remote_ip_location_region) + |> put_change(:last_seen_remote_ip_location_city, context.remote_ip_location_city) + |> put_change(:last_seen_remote_ip_location_lat, context.remote_ip_location_lat) + |> put_change(:last_seen_remote_ip_location_lon, context.remote_ip_location_lon) + |> put_change(:last_seen_at, DateTime.utc_now()) + |> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a) + end + + def delete(%Token{} = token) do + token + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end +end diff --git a/elixir/apps/domain/lib/domain/tokens/token/query.ex b/elixir/apps/domain/lib/domain/tokens/token/query.ex new file mode 100644 index 000000000..bac3219fa --- /dev/null +++ b/elixir/apps/domain/lib/domain/tokens/token/query.ex @@ -0,0 +1,50 @@ +defmodule Domain.Tokens.Token.Query do + use Domain, :query + + def all do + from(tokens in Domain.Tokens.Token, as: :tokens) + end + + def not_deleted do + all() + |> where([tokens: tokens], is_nil(tokens.deleted_at)) + end + + def not_expired(queryable \\ not_deleted()) do + where(queryable, [tokens: tokens], tokens.expires_at > ^DateTime.utc_now()) + end + + def expired(queryable \\ not_deleted()) do + where(queryable, [tokens: tokens], tokens.expires_at <= ^DateTime.utc_now()) + end + + def by_id(queryable \\ not_deleted(), id) do + where(queryable, [tokens: tokens], tokens.id == ^id) + end + + def by_type(queryable \\ not_deleted(), type) do + where(queryable, [tokens: tokens], tokens.type == ^type) + end + + def by_account_id(queryable \\ not_deleted(), account_id) do + where(queryable, [tokens: tokens], tokens.account_id == ^account_id) + end + + def by_actor_id(queryable \\ not_deleted(), actor_id) do + queryable + |> with_joined_identity() + |> where([identity: identity], identity.actor_id == ^actor_id) + end + + def with_joined_account(queryable \\ not_deleted()) do + with_named_binding(queryable, :account, fn queryable, binding -> + join(queryable, :inner, [tokens: tokens], account in assoc(tokens, ^binding), as: ^binding) + end) + end + + def with_joined_identity(queryable \\ not_deleted()) do + with_named_binding(queryable, :identity, fn queryable, binding -> + join(queryable, :inner, [tokens: tokens], identity in assoc(tokens, ^binding), as: ^binding) + end) + end +end diff --git a/elixir/apps/domain/lib/domain/validator.ex b/elixir/apps/domain/lib/domain/validator.ex index abd1c801a..9c65c811c 100644 --- a/elixir/apps/domain/lib/domain/validator.ex +++ b/elixir/apps/domain/lib/domain/validator.ex @@ -298,24 +298,48 @@ defmodule Domain.Validator do end @doc """ - Takes value from `value_field` and puts it's hash to `hash_field`. + Takes value from `value_field` and puts it's hash of a given type to `hash_field`. """ - def put_hash(%Ecto.Changeset{} = changeset, value_field, to: hash_field) do - with {:ok, value} when is_binary(value) and value != "" <- - fetch_change(changeset, value_field) do - put_change(changeset, hash_field, Domain.Crypto.hash(value)) + def put_hash(%Ecto.Changeset{} = changeset, value_field, type, opts) do + hash_field = Keyword.fetch!(opts, :to) + salt_field = Keyword.get(opts, :with_salt) + nonce_field = Keyword.get(opts, :with_nonce) + + with {:ok, value} <- fetch_value(changeset, value_field), + {:ok, nonce} <- fetch_hash_component(changeset, nonce_field), + {:ok, salt} <- fetch_hash_component(changeset, salt_field) do + put_change(changeset, hash_field, Domain.Crypto.hash(type, nonce <> value <> salt)) else _ -> changeset end end + defp fetch_value(changeset, value_field) do + case fetch_change(changeset, value_field) do + {:ok, ""} -> :error + {:ok, value} when is_binary(value) -> {:ok, value} + _other -> :error + end + end + + defp fetch_hash_component(_changeset, nil) do + {:ok, ""} + end + + defp fetch_hash_component(changeset, salt_field) do + case fetch_change(changeset, salt_field) do + {:ok, salt} when is_binary(salt) -> {:ok, salt} + :error -> {:ok, ""} + end + end + @doc """ Validates that value in a given `value_field` equals to hash stored in `hash_field`. """ - def validate_hash(changeset, value_field, hash_field: hash_field) do + def validate_hash(changeset, value_field, type, hash_field: hash_field) do with {:data, hash} <- fetch_field(changeset, hash_field) do validate_change(changeset, value_field, fn value_field, token -> - if Domain.Crypto.equal?(token, hash) do + if Domain.Crypto.equal?(type, token, hash) do [] else [{value_field, {"is invalid", [validation: :hash]}}] diff --git a/elixir/apps/domain/priv/repo/migrations/20230920172332_add_tokens.exs b/elixir/apps/domain/priv/repo/migrations/20230920172332_add_tokens.exs new file mode 100644 index 000000000..ea6c38e43 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20230920172332_add_tokens.exs @@ -0,0 +1,53 @@ +defmodule Domain.Repo.Migrations.AddTokens do + use Ecto.Migration + + def change do + create table(:tokens, primary_key: false) do + add(:id, :uuid, primary_key: true) + + add(:type, :string, null: false) + add(:secret_salt, :string, null: false) + add(:secret_hash, :string, null: false) + + add( + :identity_id, + references(:auth_identities, type: :binary_id, on_delete: :delete_all) + # TODO? null: false? + ) + + add(:created_by, :string, null: false) + add(:created_by_identity_id, references(:auth_identities, type: :binary_id)) + add(:created_by_user_agent, :string, null: false) + add(:created_by_remote_ip, :inet, null: false) + + add(:last_seen_at, :utc_datetime_usec) + add(:last_seen_remote_ip, :inet) + add(:last_seen_remote_ip_location_region, :text) + add(:last_seen_remote_ip_location_city, :text) + add(:last_seen_remote_ip_location_lat, :float) + add(:last_seen_remote_ip_location_lon, :float) + add(:last_seen_user_agent, :string) + add(:last_seen_version, :string) + + add(:account_id, references(:accounts, type: :binary_id, on_delete: :delete_all), + null: false + ) + + add(:expires_at, :utc_datetime_usec, null: false) + add(:deleted_at, :utc_datetime_usec) + timestamps() + end + + create( + constraint(:tokens, :assoc_not_null, + check: """ + (type = 'browser' AND identity_id IS NOT NULL) + OR (type = 'client' AND identity_id IS NOT NULL) + OR (type IN ('relay', 'gateway', 'email', 'api_client')) + """ + ) + ) + + create(index(:tokens, [:account_id, :type], where: "deleted_at IS NULL")) + end +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index e3cb474d4..9dc68b96f 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -185,36 +185,59 @@ other_admin_actor_email = "other@localhost" } }) -unprivileged_actor_token = unprivileged_actor_email_identity.provider_virtual_state.sign_in_token -admin_actor_token = admin_actor_email_identity.provider_virtual_state.sign_in_token +unprivileged_actor_email_token = + unprivileged_actor_email_identity.provider_virtual_state.sign_in_token + +admin_actor_email_token = admin_actor_email_identity.provider_virtual_state.sign_in_token + +unprivileged_actor_context = %Auth.Context{ + type: :browser, + user_agent: "Debian/11.0.0 connlib/0.1.0", + remote_ip: {172, 28, 0, 100}, + remote_ip_location_region: "UA", + remote_ip_location_city: "Kyiv", + remote_ip_location_lat: 50.4333, + remote_ip_location_lon: 30.5167 +} + +nonce = "n" + +{:ok, unprivileged_actor_token} = + Auth.create_token(unprivileged_actor_email_identity, unprivileged_actor_context, nonce, nil) unprivileged_subject = Auth.build_subject( + unprivileged_actor_token, unprivileged_actor_userpass_identity, - DateTime.utc_now() |> DateTime.add(365, :day), - %Auth.Context{ - user_agent: "Debian/11.0.0 connlib/0.1.0", - remote_ip: {172, 28, 0, 100}, - remote_ip_location_region: "UA", - remote_ip_location_city: "Kyiv", - remote_ip_location_lat: 50.4333, - remote_ip_location_lon: 30.5167 - } + unprivileged_actor_context ) +admin_actor_context = %Auth.Context{ + type: :browser, + user_agent: "iOS/12.5 (iPhone) connlib/0.7.412", + remote_ip: {100, 64, 100, 58}, + remote_ip_location_region: "UA", + remote_ip_location_city: "Kyiv", + remote_ip_location_lat: 50.4333, + remote_ip_location_lon: 30.5167 +} + +{:ok, admin_actor_token} = + Auth.create_token(admin_actor_email_identity, admin_actor_context, nonce, nil) + admin_subject = Auth.build_subject( + admin_actor_token, admin_actor_email_identity, - nil, - %Auth.Context{user_agent: "iOS/12.5 (iPhone) connlib/0.7.412", remote_ip: {100, 64, 100, 58}} + admin_actor_context ) IO.puts("Created users: ") for {type, login, password, email_token} <- [ {unprivileged_actor.type, unprivileged_actor_email, "Firezone1234", - unprivileged_actor_token}, - {admin_actor.type, admin_actor_email, "Firezone1234", admin_actor_token} + unprivileged_actor_email_token}, + {admin_actor.type, admin_actor_email, "Firezone1234", admin_actor_email_token} ] do IO.puts(" #{login}, #{type}, password: #{password}, email token: #{email_token} (exp in 15m)") end @@ -599,10 +622,27 @@ IO.puts("Policies Created") IO.puts("") {:ok, unprivileged_subject_client_token} = - Auth.create_client_token_from_subject(unprivileged_subject) + Auth.create_token( + unprivileged_actor_email_identity, + %{unprivileged_actor_context | type: :client}, + nonce, + nil + ) + +unprivileged_subject_client_token = + maybe_repo_update.(unprivileged_subject_client_token, + id: "7da7d1cd-111c-44a7-b5ac-4027b9d230e5", + secret_salt: "kKKA7dtf3TJk0-1O2D9N1w", + secret_fragment: "AiIy_6pBk-WLeRAPzzkCFXNqIZKWBs2Ddw_2vgIQvFg", + secret_hash: "5c1d6795ea1dd08b6f4fd331eeaffc12032ba171d227f328446f2d26b96437e5" + ) IO.puts("Created client tokens:") -IO.puts(" #{unprivileged_actor_email} token: #{unprivileged_subject_client_token}") + +IO.puts( + " #{unprivileged_actor_email} token: #{nonce <> Domain.Tokens.encode_fragment!(unprivileged_subject_client_token)}" +) + IO.puts("") {:ok, _resource, flow} = diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index 8dfa74aff..804b9d59c 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -37,7 +37,8 @@ defmodule Domain.AccountsTest do assert fetch_account_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Accounts.Authorizer.view_accounts_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Accounts.Authorizer.view_accounts_permission()]}} end end @@ -74,7 +75,8 @@ defmodule Domain.AccountsTest do assert fetch_account_by_id_or_slug(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Accounts.Authorizer.view_accounts_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Accounts.Authorizer.view_accounts_permission()]}} end end diff --git a/elixir/apps/domain/test/domain/actors_test.exs b/elixir/apps/domain/test/domain/actors_test.exs index 09d26d722..82f306eda 100644 --- a/elixir/apps/domain/test/domain/actors_test.exs +++ b/elixir/apps/domain/test/domain/actors_test.exs @@ -70,7 +70,8 @@ defmodule Domain.ActorsTest do assert fetch_group_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -130,7 +131,8 @@ defmodule Domain.ActorsTest do assert list_groups(subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -252,7 +254,8 @@ defmodule Domain.ActorsTest do assert peek_group_actors([], 3, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -371,7 +374,8 @@ defmodule Domain.ActorsTest do assert peek_actor_groups([], 3, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -919,7 +923,8 @@ defmodule Domain.ActorsTest do assert create_group(%{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -1037,7 +1042,8 @@ defmodule Domain.ActorsTest do assert update_group(group, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end test "raises if group is synced", %{ @@ -1091,7 +1097,8 @@ defmodule Domain.ActorsTest do assert delete_group(group, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end test "raises if group is synced", %{ @@ -1152,7 +1159,8 @@ defmodule Domain.ActorsTest do assert fetch_actors_count_by_type(:foo, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -1260,7 +1268,8 @@ defmodule Domain.ActorsTest do assert fetch_actor_by_id("foo", subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end test "associations are preloaded when opts given" do @@ -1318,6 +1327,7 @@ defmodule Domain.ActorsTest do identity: nil, actor: %{id: Ecto.UUID.generate()}, account: %{id: Ecto.UUID.generate()}, + token_id: nil, context: nil, expires_at: nil, permissions: MapSet.new() @@ -1350,7 +1360,8 @@ defmodule Domain.ActorsTest do assert list_actors(subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end test "associations are preloaded when opts given" do @@ -1451,7 +1462,8 @@ defmodule Domain.ActorsTest do assert create_actor(account, attrs, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end test "returns error when subject tries to create an account in another account", %{ @@ -1554,7 +1566,8 @@ defmodule Domain.ActorsTest do assert update_actor(actor, %{type: :foo}, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end test "allows changing not synced memberships", %{account: account, subject: subject} do @@ -1778,7 +1791,8 @@ defmodule Domain.ActorsTest do assert disable_actor(actor, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -1837,7 +1851,8 @@ defmodule Domain.ActorsTest do assert enable_actor(actor, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end @@ -2007,7 +2022,8 @@ defmodule Domain.ActorsTest do assert delete_actor(actor, subject) == {:error, {:unauthorized, - [missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Actors.Authorizer.manage_actors_permission()]}} end end diff --git a/elixir/apps/domain/test/domain/auth/adapters/email_test.exs b/elixir/apps/domain/test/domain/auth/adapters/email_test.exs index eab152cd3..e19610f8c 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/email_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/email_test.exs @@ -30,7 +30,11 @@ defmodule Domain.Auth.Adapters.EmailTest do provider_virtual_state: %{sign_in_token: sign_in_token} } = changeset.changes - assert Domain.Crypto.equal?(sign_in_token <> sign_in_token_salt, sign_in_token_hash) + assert Domain.Crypto.equal?( + :argon2, + sign_in_token <> sign_in_token_salt, + sign_in_token_hash + ) end test "trims provider identifier", %{provider: provider, changeset: changeset} do @@ -108,7 +112,12 @@ defmodule Domain.Auth.Adapters.EmailTest do sign_in_token: sign_in_token } = identity.provider_virtual_state - assert Domain.Crypto.equal?(sign_in_token <> sign_in_token_salt, sign_in_token_hash) + assert Domain.Crypto.equal?( + :argon2, + sign_in_token <> sign_in_token_salt, + sign_in_token_hash + ) + assert %DateTime{} = sign_in_token_created_at end end @@ -158,7 +167,7 @@ defmodule Domain.Auth.Adapters.EmailTest do account: account, provider: provider, provider_state: %{ - "sign_in_token_hash" => Domain.Crypto.hash("dummy_token" <> "salty"), + "sign_in_token_hash" => Domain.Crypto.hash(:argon2, "dummy_token" <> "salty"), "sign_in_token_created_at" => DateTime.to_iso8601(forty_seconds_ago), "sign_in_token_salt" => "salty" } diff --git a/elixir/apps/domain/test/domain/auth/adapters/token_test.exs b/elixir/apps/domain/test/domain/auth/adapters/token_test.exs index 85afffb7e..110ef677d 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/token_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/token_test.exs @@ -28,7 +28,7 @@ defmodule Domain.Auth.Adapters.TokenTest do assert %{"secret_hash" => secret_hash} = state assert %{changes: %{secret: secret}} = virtual_state - assert Domain.Crypto.equal?(secret, secret_hash) + assert Domain.Crypto.equal?(:argon2, secret, secret_hash) end test "returns error on invalid attrs", %{provider: provider} do @@ -118,7 +118,7 @@ defmodule Domain.Auth.Adapters.TokenTest do |> Ecto.Changeset.change( provider_state: %{ "expires_at" => DateTime.utc_now() |> DateTime.add(-1, :second), - "secret_hash" => Domain.Crypto.hash("foo") + "secret_hash" => Domain.Crypto.hash(:argon2, "foo") } ) |> Repo.update!() diff --git a/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs b/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs index 1faeab647..34e8d24c6 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs @@ -28,7 +28,7 @@ defmodule Domain.Auth.Adapters.UserPassTest do assert %{provider_state: state, provider_virtual_state: %{}} = changeset.changes assert %{"password_hash" => password_hash} = state - assert Domain.Crypto.equal?("Firezone1234", password_hash) + assert Domain.Crypto.equal?(:argon2, "Firezone1234", password_hash) end test "returns error on invalid attrs", %{provider: provider} do diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 2c12e5940..a26a996e3 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -1,9 +1,11 @@ defmodule Domain.AuthTest do use Domain.DataCase import Domain.Auth - alias Domain.Auth + alias Domain.{Auth, Tokens} alias Domain.Auth.Authorizer + # Providers + describe "list_provider_adapters/0" do test "returns list of enabled adapters for an account" do assert {:ok, adapters} = list_provider_adapters() @@ -15,42 +17,6 @@ defmodule Domain.AuthTest do end end - describe "fetch_provider_by_id/1" do - test "returns error when provider does not exist" do - assert fetch_provider_by_id(Ecto.UUID.generate()) == {:error, :not_found} - end - - test "returns error when on invalid UUIDv4" do - assert fetch_provider_by_id("foo") == {:error, :not_found} - end - - test "returns error when provider is deleted" do - account = Fixtures.Accounts.create_account() - Fixtures.Auth.create_userpass_provider(account: account) - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - provider = Fixtures.Auth.create_email_provider(account: account) - - identity = - Fixtures.Auth.create_identity( - actor: [type: :account_admin_user], - account: account, - provider: provider - ) - - subject = Fixtures.Auth.create_subject(identity: identity) - {:ok, _provider} = delete_provider(provider, subject) - - assert fetch_provider_by_id(provider.id) == {:error, :not_found} - end - - test "returns provider" do - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - provider = Fixtures.Auth.create_email_provider() - assert {:ok, fetched_provider} = fetch_provider_by_id(provider.id) - assert fetched_provider.id == provider.id - end - end - describe "fetch_provider_by_id/2" do setup do account = Fixtures.Accounts.create_account() @@ -83,54 +49,27 @@ defmodule Domain.AuthTest do assert fetched_provider.id == provider.id end + test "does not return provider from other accounts", %{subject: subject} do + provider = Fixtures.Auth.create_userpass_provider() + assert fetch_provider_by_id(provider.id, subject) == {:error, :not_found} + end + test "returns provider", %{account: account, subject: subject} do provider = Fixtures.Auth.create_userpass_provider(account: account) assert {:ok, fetched_provider} = fetch_provider_by_id(provider.id, subject) assert fetched_provider.id == provider.id end - end - describe "fetch_active_provider_by_id/2" do - setup do - account = Fixtures.Accounts.create_account() - actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) - identity = Fixtures.Auth.create_identity(account: account, actor: actor) - subject = Fixtures.Auth.create_subject(identity: identity) + test "returns error when subject can not view providers", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) - %{ - account: account, - actor: actor, - identity: identity, - subject: subject - } - end - - test "returns error when provider does not exist", %{subject: subject} do - assert fetch_active_provider_by_id(Ecto.UUID.generate(), subject) == - {:error, :not_found} - end - - test "returns error when on invalid UUIDv4", %{subject: subject} do - assert fetch_active_provider_by_id("foo", subject) == {:error, :not_found} - end - - test "returns error when provider is disabled", %{account: account, subject: subject} do - provider = Fixtures.Auth.create_userpass_provider(account: account) - {:ok, _provider} = disable_provider(provider, subject) - assert fetch_active_provider_by_id(provider.id, subject) == {:error, :not_found} - end - - test "returns error when provider is deleted", %{account: account, subject: subject} do - provider = Fixtures.Auth.create_userpass_provider(account: account) - {:ok, _provider} = delete_provider(provider, subject) - - assert fetch_active_provider_by_id(provider.id, subject) == {:error, :not_found} - end - - test "returns provider", %{account: account, subject: subject} do - provider = Fixtures.Auth.create_userpass_provider(account: account) - assert {:ok, fetched_provider} = fetch_active_provider_by_id(provider.id, subject) - assert fetched_provider.id == provider.id + assert fetch_provider_by_id("foo", subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end end @@ -192,7 +131,78 @@ defmodule Domain.AuthTest do end end - describe "list_providers_for_account/2" do + describe "fetch_active_provider_by_adapter/3" do + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + test "returns error when provider does not exist", %{subject: subject} do + assert fetch_active_provider_by_adapter(:email, subject) == {:error, :not_found} + assert fetch_active_provider_by_adapter(:token, subject) == {:error, :not_found} + assert fetch_active_provider_by_adapter(:userpass, subject) == {:error, :not_found} + end + + test "raises when invalid adapter is used", %{subject: subject} do + for adapter <- [:foo, :openid_connect, :google_workspace] do + assert_raise FunctionClauseError, fn -> + fetch_active_provider_by_adapter(adapter, subject) + end + end + end + + test "returns error when provider is disabled", %{account: account, subject: subject} do + provider = Fixtures.Auth.create_token_provider(account: account) + {:ok, _provider} = disable_provider(provider, subject) + + assert fetch_active_provider_by_adapter(:token, subject) == {:error, :not_found} + end + + test "returns error when provider is deleted", %{account: account, subject: subject} do + provider = Fixtures.Auth.create_token_provider(account: account) + {:ok, _provider} = delete_provider(provider, subject) + + assert fetch_active_provider_by_adapter(:token, subject) == {:error, :not_found} + end + + test "returns provider and preloads", %{account: account, subject: subject} do + provider = Fixtures.Auth.create_token_provider(account: account) + + assert {:ok, fetched_provider} = + fetch_active_provider_by_adapter(:token, subject, preload: [:account]) + + assert fetched_provider.id == provider.id + assert Ecto.assoc_loaded?(fetched_provider.account) + end + + test "does not return providers from other account", %{subject: subject} do + Fixtures.Auth.create_token_provider() + assert fetch_active_provider_by_adapter(:token, subject) == {:error, :not_found} + end + + test "returns error when subject can not view providers", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert fetch_active_provider_by_adapter(:email, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} + end + end + + describe "list_providers/2" do test "returns all not soft-deleted providers for a given account" do account = Fixtures.Accounts.create_account() @@ -201,6 +211,9 @@ defmodule Domain.AuthTest do email_provider = Fixtures.Auth.create_email_provider(account: account) token_provider = Fixtures.Auth.create_token_provider(account: account) + Fixtures.Auth.create_email_provider() + Fixtures.Auth.create_token_provider() + identity = Fixtures.Auth.create_identity( actor: [type: :account_admin_user], @@ -213,22 +226,36 @@ defmodule Domain.AuthTest do {:ok, _provider} = disable_provider(token_provider, subject) {:ok, _provider} = delete_provider(email_provider, subject) - assert {:ok, providers} = list_providers_for_account(account, subject) + assert {:ok, providers} = list_providers(subject) assert length(providers) == 2 end + test "doesn't return providers from other accounts" do + Fixtures.Auth.create_token_provider() + Fixtures.Auth.create_userpass_provider() + + subject = Fixtures.Auth.create_subject() + assert {:ok, [provider]} = list_providers(subject) + assert provider.account_id == subject.account.id + end + test "returns error when subject can not manage providers" do account = Fixtures.Accounts.create_account() identity = - Fixtures.Auth.create_identity(actor: [type: :account_admin_user], account: account) + Fixtures.Auth.create_identity( + actor: [type: :account_admin_user], + account: account + ) subject = Fixtures.Auth.create_subject(identity: identity) subject = Fixtures.Auth.remove_permissions(subject) - assert list_providers_for_account(account, subject) == + assert list_providers(subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end end @@ -256,6 +283,14 @@ defmodule Domain.AuthTest do assert {:ok, [provider]} = list_active_providers_for_account(account) assert provider.id == userpass_provider.id end + + test "doesn't return providers from other accounts" do + Fixtures.Auth.create_token_provider() + Fixtures.Auth.create_userpass_provider() + + account = Fixtures.Accounts.create_account() + assert list_active_providers_for_account(account) == {:ok, []} + end end describe "list_providers_pending_token_refresh_by_adapter/1" do @@ -274,6 +309,23 @@ defmodule Domain.AuthTest do Domain.Fixture.update!(provider, %{ disabled_at: DateTime.utc_now(), adapter_state: %{ + "access_token" => "OIDC_ACCESS_TOKEN", + "refresh_token" => "OIDC_REFRESH_TOKEN", + "expires_at" => DateTime.utc_now() + } + }) + + assert list_providers_pending_token_refresh_by_adapter(:google_workspace) == {:ok, []} + end + + test "ignores deleted providers" do + {provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() + + Domain.Fixture.update!(provider, %{ + deleted_at: DateTime.utc_now(), + adapter_state: %{ + "access_token" => "OIDC_ACCESS_TOKEN", + "refresh_token" => "OIDC_REFRESH_TOKEN", "expires_at" => DateTime.utc_now() } }) @@ -287,6 +339,9 @@ defmodule Domain.AuthTest do Domain.Fixture.update!(provider, %{ provisioner: :manual, adapter_state: %{ + "access_token" => "OIDC_ACCESS_TOKEN", + "refresh_token" => "OIDC_REFRESH_TOKEN", + "claims" => "openid email profile offline_access", "expires_at" => DateTime.utc_now() } }) @@ -294,7 +349,7 @@ defmodule Domain.AuthTest do assert list_providers_pending_token_refresh_by_adapter(:google_workspace) == {:ok, []} end - test "returns providers with tokens that will expire in ~1 hour" do + test "returns providers with tokens that will expire in ~30 minutes" do {provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() Domain.Fixture.update!(provider, %{ @@ -311,6 +366,21 @@ defmodule Domain.AuthTest do assert fetched_provider.id == provider.id end + + test "doesn't return providers that don't have refresh tokens" do + {provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() + + Domain.Fixture.update!(provider, %{ + adapter_state: %{ + "access_token" => "OIDC_ACCESS_TOKEN", + "refresh_token" => nil, + "expires_at" => DateTime.utc_now() |> DateTime.add(28, :minute), + "claims" => "openid email profile offline_access" + } + }) + + assert list_providers_pending_token_refresh_by_adapter(:google_workspace) == {:ok, []} + end end describe "list_providers_pending_sync_by_adapter/1" do @@ -337,6 +407,19 @@ defmodule Domain.AuthTest do assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []} end + test "ignores deleted providers" do + {provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() + + Domain.Fixture.update!(provider, %{ + deleted_at: DateTime.utc_now(), + adapter_state: %{ + "expires_at" => DateTime.utc_now() + } + }) + + assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []} + end + test "ignores non-custom provisioners" do {provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() @@ -350,7 +433,7 @@ defmodule Domain.AuthTest do assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []} end - test "returns providers with tokens that synced more than 10m ago" do + test "returns providers that synced more than 10m ago" do {provider1, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() {provider2, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() @@ -575,7 +658,9 @@ defmodule Domain.AuthTest do assert create_provider(account, %{}, subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end test "returns error when subject tries to create a provider in another account", %{ @@ -716,7 +801,9 @@ defmodule Domain.AuthTest do assert update_provider(provider, %{}, subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end test "returns error when subject tries to update an account in another account", %{ @@ -870,7 +957,9 @@ defmodule Domain.AuthTest do assert disable_provider(provider, subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end end @@ -930,7 +1019,9 @@ defmodule Domain.AuthTest do assert enable_provider(provider, subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end end @@ -1071,7 +1162,9 @@ defmodule Domain.AuthTest do assert delete_provider(provider, subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_providers_permission()]}} end end @@ -1087,15 +1180,186 @@ defmodule Domain.AuthTest do end end - describe "fetch_identity_by_id/1" do - test "returns error when identity does not exist" do - assert fetch_identity_by_id(Ecto.UUID.generate()) == {:error, :not_found} + # Identities + + describe "fetch_active_identity_by_provider_and_identifier/3" do + test "returns nothing when identity doesn't exist" do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + + assert fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) == + {:error, :not_found} end - test "returns identity" do - identity = Fixtures.Auth.create_identity() - assert {:ok, fetched_identity} = fetch_identity_by_id(identity.id) + test "returns error when identity actor is deleted" do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + + actor = Fixtures.Actors.create_actor(account: account) + + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + provider_identifier: provider_identifier + ) + + assert {:ok, _} = + fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) + + Fixtures.Actors.delete(actor) + + assert fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) == + {:error, :not_found} + end + + test "returns error when identity actor is disabled" do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + + actor = Fixtures.Actors.create_actor(account: account) + + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + provider_identifier: provider_identifier + ) + + assert {:ok, _} = + fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) + + Fixtures.Actors.disable(actor) + + assert fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) == + {:error, :not_found} + end + + test "returns error when identity provider is deleted" do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + + actor = Fixtures.Actors.create_actor(account: account) + + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + provider_identifier: provider_identifier + ) + + assert {:ok, _} = + fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) + + Fixtures.Auth.delete_provider(provider) + + assert fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) == + {:error, :not_found} + end + + test "returns error when identity provider is disabled" do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + + actor = Fixtures.Actors.create_actor(account: account) + + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + provider_identifier: provider_identifier + ) + + assert {:ok, _} = + fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) + + Fixtures.Auth.disable_provider(provider) + + assert fetch_active_identity_by_provider_and_identifier(provider, provider_identifier) == + {:error, :not_found} + end + + test "returns identity by provider identifier" do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + + identity = + Fixtures.Auth.create_identity( + account: account, + provider: provider, + provider_identifier: provider_identifier + ) + + assert {:ok, fetched_identity} = + fetch_active_identity_by_provider_and_identifier(provider, provider_identifier, + preload: [:account] + ) + assert fetched_identity.id == identity.id + assert Ecto.assoc_loaded?(fetched_identity.account) + end + end + + describe "fetch_identity_by_id/2" do + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + test "returns error when identity does not exist", %{subject: subject} do + assert fetch_identity_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found} + assert fetch_identity_by_id("foo", subject) == {:error, :not_found} + end + + test "returns error when identity is deleted", %{account: account, subject: subject} do + identity = Fixtures.Auth.create_identity(account: account) + {:ok, _identity} = delete_identity(identity, subject) + + assert fetch_identity_by_id(identity.id, subject) == {:error, :not_found} + end + + test "returns identity", %{account: account, subject: subject} do + identity = Fixtures.Auth.create_identity(account: account) + assert {:ok, fetched_identity} = fetch_identity_by_id(identity.id, subject) + assert fetched_identity.id == identity.id + end + + test "does not return identities from other account", %{subject: subject} do + identity = Fixtures.Auth.create_identity() + assert fetch_identity_by_id(identity.id, subject) == {:error, :not_found} + end + + test "returns error when subject can not view identities", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert fetch_identity_by_id("foo", subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_identities_permission()]}} end end @@ -1124,6 +1388,18 @@ defmodule Domain.AuthTest do vault_provider.id => 2 }} end + + test "doesn't count identities in other accounts" do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + Fixtures.Auth.create_identity() + + assert fetch_identities_count_grouped_by_provider_id(subject) == + {:ok, %{identity.provider_id => 1}} + end end describe "sync_provider_identities_multi/2" do @@ -1345,11 +1621,10 @@ defmodule Domain.AuthTest do end describe "upsert_identity/3" do - test "creates an identity" do + setup do account = Fixtures.Accounts.create_account() Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) provider = Fixtures.Auth.create_email_provider(account: account) - provider_identifier = Fixtures.Auth.random_provider_identifier(provider) actor = Fixtures.Actors.create_actor( @@ -1358,6 +1633,29 @@ defmodule Domain.AuthTest do provider: provider ) + %{account: account, provider: provider, actor: actor} + end + + test "returns changeset error when required attrs are missing", %{ + provider: provider, + actor: actor + } do + attrs = %{} + + assert {:error, changeset} = upsert_identity(actor, provider, attrs) + + assert errors_on(changeset) == %{ + provider_identifier: ["can't be blank"], + provider_identifier_confirmation: ["email does not match"] + } + end + + test "creates an identity", %{ + provider: provider, + actor: actor + } do + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + attrs = %{ provider_identifier: provider_identifier, provider_identifier_confirmation: provider_identifier @@ -1377,12 +1675,12 @@ defmodule Domain.AuthTest do assert is_nil(identity.deleted_at) end - test "updates existing identity" do - account = Fixtures.Accounts.create_account() - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - provider = Fixtures.Auth.create_email_provider(account: account) + test "updates existing identity", %{ + account: account, + provider: provider, + actor: actor + } do provider_identifier = Fixtures.Auth.random_provider_identifier(provider) - actor = Fixtures.Actors.create_actor(account: account, provider: provider) identity = Fixtures.Auth.create_identity( @@ -1390,7 +1688,7 @@ defmodule Domain.AuthTest do provider: provider, provider_identifier: provider_identifier, actor: actor, - provider_state: %{"foo" => "bar"} + provider_virtual_state: %{"foo" => "bar"} ) attrs = %{ @@ -1400,23 +1698,16 @@ defmodule Domain.AuthTest do assert {:ok, updated_identity} = upsert_identity(actor, provider, attrs) - assert Repo.aggregate(Auth.Identity, :count) == 1 + assert Repo.one(Auth.Identity).id == updated_identity.id + assert updated_identity.provider_virtual_state != identity.provider_virtual_state assert updated_identity.provider_state != identity.provider_state end - test "returns error when identifier is invalid" do - account = Fixtures.Accounts.create_account() - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - provider = Fixtures.Auth.create_email_provider(account: account) - - actor = - Fixtures.Actors.create_actor( - type: :account_admin_user, - account: account, - provider: provider - ) - + test "returns error when identifier is invalid", %{ + provider: provider, + actor: actor + } do provider_identifier = Ecto.UUID.generate() attrs = %{ @@ -1488,11 +1779,10 @@ defmodule Domain.AuthTest do end describe "create_identity/4" do - test "creates an identity" do + setup do account = Fixtures.Accounts.create_account() Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) provider = Fixtures.Auth.create_email_provider(account: account) - provider_identifier = Fixtures.Auth.random_provider_identifier(provider) actor = Fixtures.Actors.create_actor( @@ -1503,34 +1793,110 @@ defmodule Domain.AuthTest do subject = Fixtures.Auth.create_subject(actor: actor) + %{account: account, provider: provider, actor: actor, subject: subject} + end + + test "returns changeset error when required attrs are missing", %{ + provider: provider, + actor: actor, + subject: subject + } do + attrs = %{} + + assert {:error, changeset} = create_identity(actor, provider, attrs, subject) + + assert errors_on(changeset) == %{ + provider_identifier: ["can't be blank"], + provider_identifier_confirmation: ["email does not match"] + } + end + + test "creates an identity", %{ + provider: provider, + actor: actor, + subject: subject + } do + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) + attrs = %{ provider_identifier: provider_identifier, provider_identifier_confirmation: provider_identifier } - assert {:ok, _identity} = create_identity(actor, provider, attrs, subject) + assert {:ok, identity} = create_identity(actor, provider, attrs, subject) + + assert identity.provider_id == provider.id + assert identity.provider_identifier == provider_identifier + assert identity.actor_id == actor.id + + assert %{"sign_in_token_created_at" => _, "sign_in_token_hash" => _} = + identity.provider_state + + assert %{sign_in_token: _} = identity.provider_virtual_state + assert identity.account_id == provider.account_id + assert is_nil(identity.deleted_at) end - test "returns error on missing permissions" do - account = Fixtures.Accounts.create_account() - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - provider = Fixtures.Auth.create_email_provider(account: account) + test "returns error when identity already exists", %{ + account: account, + provider: provider, + actor: actor, + subject: subject + } do + provider_identifier = Fixtures.Auth.random_provider_identifier(provider) - actor = - Fixtures.Actors.create_actor( - type: :account_admin_user, - account: account, - provider: provider - ) + Fixtures.Auth.create_identity( + account: account, + provider: provider, + provider_identifier: provider_identifier, + actor: actor + ) - subject = - Fixtures.Auth.create_subject(actor: actor) - |> Fixtures.Auth.remove_permissions() + attrs = %{ + provider_identifier: provider_identifier, + provider_identifier_confirmation: provider_identifier + } + + assert {:error, changeset} = create_identity(actor, provider, attrs, subject) + assert "has already been taken" in errors_on(changeset).provider_identifier + end + + test "returns error when identifier is invalid", %{ + provider: provider, + actor: actor, + subject: subject + } do + provider_identifier = Ecto.UUID.generate() + + attrs = %{ + provider_identifier: provider_identifier, + provider_identifier_confirmation: provider_identifier + } + + assert {:error, changeset} = create_identity(actor, provider, attrs, subject) + assert errors_on(changeset) == %{provider_identifier: ["is an invalid email address"]} + + attrs = %{provider_identifier: nil, provider_identifier_confirmation: nil} + assert {:error, changeset} = create_identity(actor, provider, attrs, subject) + assert errors_on(changeset) == %{provider_identifier: ["can't be blank"]} + + attrs = %{provider_identifier: Fixtures.Auth.email()} + assert {:error, changeset} = create_identity(actor, provider, attrs, subject) + assert errors_on(changeset) == %{provider_identifier_confirmation: ["email does not match"]} + end + + test "returns error on missing permissions", %{ + provider: provider, + actor: actor, + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) assert create_identity(actor, provider, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Authorizer.manage_identities_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Authorizer.manage_identities_permission()]}} end end @@ -1599,7 +1965,7 @@ defmodule Domain.AuthTest do assert errors_on(changeset) == %{provider_identifier_confirmation: ["email does not match"]} end - test "updates existing identity" do + test "returns error when identity already exists" do account = Fixtures.Accounts.create_account() Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) provider = Fixtures.Auth.create_email_provider(account: account) @@ -1656,7 +2022,7 @@ defmodule Domain.AuthTest do assert replace_identity(identity, attrs, subject) == {:error, :not_found} end - test "replaces existing identity with a new one", %{ + test "returns error when provider_identifier is invalid", %{ identity: identity, subject: subject } do @@ -1681,7 +2047,7 @@ defmodule Domain.AuthTest do refute Repo.get(Auth.Identity, identity.id).deleted_at end - test "returns error when provider_identifier is invalid", %{ + test "replaces existing identity with a new one", %{ identity: identity, provider: provider, subject: subject @@ -1719,14 +2085,13 @@ defmodule Domain.AuthTest do assert replace_identity(identity, attrs, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Authorizer.manage_identities_permission(), - Authorizer.manage_own_identities_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Authorizer.manage_identities_permission(), + Authorizer.manage_own_identities_permission() + ]} ]}} end end @@ -1755,6 +2120,23 @@ defmodule Domain.AuthTest do } end + test "returns error when trying to delete a synced identity", %{ + account: account, + provider: provider, + actor: actor, + subject: subject + } do + identity = + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + created_by: :provider + ) + + assert delete_identity(identity, subject) == {:error, :cant_delete_synced_identity} + end + test "deletes the identity that belongs to a subject actor", %{ account: account, provider: provider, @@ -1838,53 +2220,94 @@ defmodule Domain.AuthTest do assert delete_identity(identity, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Authorizer.manage_identities_permission(), - Authorizer.manage_own_identities_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Authorizer.manage_identities_permission(), + Authorizer.manage_own_identities_permission() + ]} ]}} end end - describe "delete_actor_identities/1" do + describe "delete_actor_identities/2" do setup do account = Fixtures.Accounts.create_account() Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) provider = Fixtures.Auth.create_email_provider(account: account) + actor = + Fixtures.Actors.create_actor( + account: account, + provider: provider, + type: :account_admin_user + ) + + identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + %{ account: account, - provider: provider + provider: provider, + actor: actor, + identity: identity, + subject: subject } end - test "removes all identities that belong to an actor", %{account: account, provider: provider} do + test "removes all identities that belong to an actor", %{ + account: account, + provider: provider, + subject: subject + } do actor = Fixtures.Actors.create_actor(account: account, provider: provider) Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) - assert Repo.aggregate(Auth.Identity.Query.all(), :count) == 3 - assert delete_actor_identities(actor) == :ok - assert Repo.aggregate(Auth.Identity.Query.not_deleted(), :count) == 0 + assert Repo.aggregate(Auth.Identity.Query.all(), :count) == 4 + assert delete_actor_identities(actor, subject) == :ok + + assert Repo.aggregate(Auth.Identity.Query.by_actor_id(actor.id), :count) == 0 end test "does not remove identities that belong to another actor", %{ account: account, - provider: provider + provider: provider, + subject: subject } do actor = Fixtures.Actors.create_actor(account: account, provider: provider) Fixtures.Auth.create_identity(account: account, provider: provider) - assert delete_actor_identities(actor) == :ok - assert Repo.aggregate(Auth.Identity.Query.all(), :count) == 1 + assert delete_actor_identities(actor, subject) == :ok + assert Repo.aggregate(Auth.Identity.Query.all(), :count) == 2 + end + + test "doesn't allow regular users to delete other users identities", %{ + account: account, + provider: provider + } do + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + subject = Fixtures.Auth.create_subject(identity: identity) + + actor = Fixtures.Actors.create_actor(account: account, provider: provider) + Fixtures.Auth.create_identity(account: account, provider: provider) + + assert delete_actor_identities(actor, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [ + Authorizer.manage_identities_permission() + ]}} + + assert Repo.aggregate(Auth.Identity.Query.all(), :count) == 3 end end - describe "sign_in/5" do + # Authentication + + describe "sign_in/4" do setup do account = Fixtures.Accounts.create_account() Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) @@ -1906,8 +2329,10 @@ defmodule Domain.AuthTest do remote_ip: remote_ip } do secret = "foo" + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, Ecto.UUID.generate(), secret, user_agent, remote_ip) == + assert sign_in(provider, Ecto.UUID.generate(), nonce, secret, context) == {:error, :unauthorized} end @@ -1919,12 +2344,14 @@ defmodule Domain.AuthTest do } do identity = Fixtures.Auth.create_identity(account: account, provider: provider) secret = "foo" + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) == + assert sign_in(provider, identity.provider_identifier, nonce, secret, context) == {:error, :unauthorized} end - test "returns subject on success using provider identifier", %{ + test "returns error when nonce is invalid", %{ account: account, provider: provider, user_agent: user_agent, @@ -1932,18 +2359,15 @@ defmodule Domain.AuthTest do } do identity = Fixtures.Auth.create_identity(account: account, provider: provider) secret = identity.provider_virtual_state.sign_in_token + nonce = "!.=" - assert {:ok, %Auth.Subject{} = subject} = - sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert subject.account.id == account.id - assert subject.actor.id == identity.actor_id - assert subject.identity.id == identity.id - assert subject.context.remote_ip == remote_ip - assert subject.context.user_agent == user_agent + assert sign_in(provider, identity.provider_identifier, nonce, secret, context) == + {:error, :malformed_request} end - test "returns subject on success using identity id", %{ + test "returns encoded token on success using provider identifier", %{ account: account, provider: provider, user_agent: user_agent, @@ -1951,40 +2375,149 @@ defmodule Domain.AuthTest do } do identity = Fixtures.Auth.create_identity(account: account, provider: provider) secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" - assert {:ok, %Auth.Subject{} = subject} = - sign_in(provider, identity.id, secret, user_agent, remote_ip) + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} + assert {:ok, token_identity, fragment} = + sign_in(provider, identity.provider_identifier, nonce, secret, context) + + refute fragment =~ nonce + + assert {:ok, subject} = authenticate(nonce <> fragment, context) assert subject.account.id == account.id assert subject.actor.id == identity.actor_id assert subject.identity.id == identity.id - assert subject.context.remote_ip == remote_ip - assert subject.context.user_agent == user_agent + assert subject.identity.id == token_identity.id + assert subject.expires_at + assert subject.context.type == context.type + + assert token = Repo.get(Tokens.Token, subject.token_id) + assert token.type == context.type + assert token.expires_at + assert token.account_id == account.id + assert token.identity_id == identity.id + assert token.created_by == :system + assert token.created_by_user_agent == context.user_agent + assert token.created_by_remote_ip.address == context.remote_ip end - test "returned subject expiration depends on user type", %{ + test "allows using identity id", %{ account: account, provider: provider, user_agent: user_agent, remote_ip: remote_ip } do + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} + + assert {:ok, _token_identity, _fragment} = + sign_in(provider, identity.id, nonce, secret, context) + end + + test "allows using client context", %{ + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" + context = %Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip} + + assert {:ok, token_identity, _fragment} = + sign_in(provider, identity.id, nonce, secret, context) + + assert token = Repo.one(Domain.Tokens.Token) + assert token.type == context.type + assert token.identity_id == token_identity.id + end + + test "raises when relay, gateway or api_client context is used", %{ + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + nonce = "nonce" + + for type <- [:relay, :gateway, :api_client] do + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + secret = identity.provider_virtual_state.sign_in_token + context = %Auth.Context{type: type, user_agent: user_agent, remote_ip: remote_ip} + + assert_raise FunctionClauseError, fn -> + sign_in(provider, identity.id, nonce, secret, context) + end + end + end + + test "returned token expiration depends on context type and user role", %{ + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + nonce = "nonce" + + # Browser session + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} + ten_hours = 10 * 60 * 60 + + ## Admin actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) secret = identity.provider_virtual_state.sign_in_token - assert {:ok, %Auth.Subject{} = subject} = - sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) + assert {:ok, identity, fragment} = + sign_in(provider, identity.provider_identifier, nonce, secret, context) - one_week = 7 * 24 * 60 * 60 - assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week - 60 * 60) + assert {:ok, subject} = authenticate(nonce <> fragment, context) + assert subject.identity.id == identity.id + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), ten_hours) + + ## Regular user actor = Fixtures.Actors.create_actor(type: :account_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) secret = identity.provider_virtual_state.sign_in_token - assert {:ok, %Auth.Subject{} = subject} = - sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) + assert {:ok, identity, fragment} = + sign_in(provider, identity.provider_identifier, nonce, secret, context) + assert {:ok, subject} = authenticate(nonce <> fragment, context) + assert subject.identity.id == identity.id + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), ten_hours) + + # Client session + context = %Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip} + one_week = 7 * 24 * 60 * 60 + + ## Admin + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) + secret = identity.provider_virtual_state.sign_in_token + + assert {:ok, identity, fragment} = + sign_in(provider, identity.provider_identifier, nonce, secret, context) + + assert {:ok, subject} = authenticate(nonce <> fragment, context) + assert subject.identity.id == identity.id + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week) + + ## Regular user + actor = Fixtures.Actors.create_actor(type: :account_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) + secret = identity.provider_virtual_state.sign_in_token + + assert {:ok, identity, fragment} = + sign_in(provider, identity.provider_identifier, nonce, secret, context) + + assert {:ok, subject} = authenticate(nonce <> fragment, context) + assert subject.identity.id == identity.id assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week) end @@ -2001,8 +2534,10 @@ defmodule Domain.AuthTest do identity = Fixtures.Auth.create_identity(account: account, provider: provider) secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) == + assert sign_in(provider, identity.provider_identifier, nonce, secret, context) == {:error, :unauthorized} end @@ -2015,10 +2550,12 @@ defmodule Domain.AuthTest do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" subject = Fixtures.Auth.create_subject(identity: identity) {:ok, identity} = delete_identity(identity, subject) + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) == + assert sign_in(provider, identity.provider_identifier, nonce, secret, context) == {:error, :unauthorized} end @@ -2034,8 +2571,10 @@ defmodule Domain.AuthTest do identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) == + assert sign_in(provider, identity.provider_identifier, nonce, secret, context) == {:error, :unauthorized} end @@ -2051,8 +2590,10 @@ defmodule Domain.AuthTest do identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) == + assert sign_in(provider, identity.provider_identifier, nonce, secret, context) == {:error, :unauthorized} end @@ -2069,31 +2610,15 @@ defmodule Domain.AuthTest do identity = Fixtures.Auth.create_identity(account: account, provider: provider) secret = identity.provider_virtual_state.sign_in_token + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) == + assert sign_in(provider, identity.provider_identifier, secret, nonce, context) == {:error, :unauthorized} end - - test "updates last signed in fields for identity on success", %{ - account: account, - provider: provider, - user_agent: user_agent, - remote_ip: remote_ip - } do - identity = Fixtures.Auth.create_identity(account: account, provider: provider) - secret = identity.provider_virtual_state.sign_in_token - - assert {:ok, _subject} = - sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip) - - assert updated_identity = Repo.one(Auth.Identity) - assert updated_identity.last_seen_at != identity.last_seen_at - assert updated_identity.last_seen_remote_ip != identity.last_seen_remote_ip - assert updated_identity.last_seen_user_agent != identity.last_seen_user_agent - end end - describe "sign_in/4" do + describe "sign_in/3" do setup do account = Fixtures.Accounts.create_account() @@ -2132,12 +2657,13 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end - test "returns error when token is invalid", %{ + test "returns error when payload is invalid", %{ bypass: bypass, provider: provider, user_agent: user_agent, @@ -2148,12 +2674,13 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end - test "returns subject on success using sub claim", %{ + test "returns encoded token on success", %{ bypass: bypass, account: account, provider: provider, @@ -2161,12 +2688,13 @@ defmodule Domain.AuthTest do remote_ip: remote_ip } do identity = Fixtures.Auth.create_identity(account: account, provider: provider) + expires_at = DateTime.utc_now() |> DateTime.add(10, :second) |> DateTime.truncate(:second) token = Mocks.OpenIDConnect.sign_openid_connect_token(%{ "sub" => identity.provider_identifier, "aud" => provider.adapter_config["client_id"], - "exp" => DateTime.utc_now() |> DateTime.add(10, :second) |> DateTime.to_unix() + "exp" => DateTime.to_unix(expires_at) }) Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) @@ -2175,17 +2703,166 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" - assert {:ok, %Auth.Subject{} = subject} = sign_in(provider, payload, user_agent, remote_ip) + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} + assert {:ok, token_identity, fragment} = sign_in(provider, nonce, payload, context) + + assert {:ok, subject} = authenticate(nonce <> fragment, context) assert subject.account.id == account.id assert subject.actor.id == identity.actor_id assert subject.identity.id == identity.id - assert subject.context.remote_ip == remote_ip - assert subject.context.user_agent == user_agent + assert subject.identity.id == token_identity.id + assert subject.context.type == context.type + + assert token = Repo.get(Tokens.Token, subject.token_id) + assert token.type == context.type + assert token.account_id == account.id + assert token.identity_id == identity.id + assert token.created_by == :system + assert token.created_by_user_agent == context.user_agent + assert token.created_by_remote_ip.address == context.remote_ip + + assert subject.expires_at == token.expires_at + assert DateTime.truncate(subject.expires_at, :second) == expires_at end - test "returned expiration duration is capped at one week for account users", %{ + test "allows using client context", %{ + bypass: bypass, + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + expires_at = DateTime.utc_now() |> DateTime.add(10, :second) + + token = + Mocks.OpenIDConnect.sign_openid_connect_token(%{ + "sub" => identity.provider_identifier, + "aud" => provider.adapter_config["client_id"], + "exp" => DateTime.to_unix(expires_at) + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + + context = %Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip} + + assert {:ok, token_identity, _fragment} = sign_in(provider, nonce, payload, context) + + assert token = Repo.one(Domain.Tokens.Token) + assert token.type == context.type + assert token.identity_id == token_identity.id + end + + test "raises when relay, gateway or api_client context is used", %{ + bypass: bypass, + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + expires_at = DateTime.utc_now() |> DateTime.add(10, :second) + + token = + Mocks.OpenIDConnect.sign_openid_connect_token(%{ + "sub" => identity.provider_identifier, + "aud" => provider.adapter_config["client_id"], + "exp" => DateTime.to_unix(expires_at) + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + + for type <- [:relay, :gateway, :api_client] do + context = %Auth.Context{type: type, user_agent: user_agent, remote_ip: remote_ip} + + assert_raise FunctionClauseError, fn -> + sign_in(provider, nonce, payload, context) + end + end + end + + test "returned expiration duration is capped at 2 weeks for admins using clients", %{ + bypass: bypass, + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) + + token = + Mocks.OpenIDConnect.sign_openid_connect_token(%{ + "sub" => identity.provider_identifier, + "aud" => provider.adapter_config["client_id"], + "exp" => DateTime.utc_now() |> DateTime.add(1_000_000, :second) |> DateTime.to_unix() + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + + context = %Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip} + + assert {:ok, _token_identity, fragment} = sign_in(provider, nonce, payload, context) + assert {:ok, subject} = authenticate(nonce <> fragment, context) + one_week = 7 * 24 * 60 * 60 + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week) + end + + test "returned expiration duration is capped at 10 hours for admins browser session", %{ + bypass: bypass, + account: account, + provider: provider, + user_agent: user_agent, + remote_ip: remote_ip + } do + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) + + token = + Mocks.OpenIDConnect.sign_openid_connect_token(%{ + "sub" => identity.provider_identifier, + "aud" => provider.adapter_config["client_id"], + "exp" => DateTime.utc_now() |> DateTime.add(1_000_000, :second) |> DateTime.to_unix() + }) + + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} + + assert {:ok, _token_identity, fragment} = sign_in(provider, nonce, payload, context) + assert {:ok, subject} = authenticate(nonce <> fragment, context) + ten_hours = 10 * 60 * 60 + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), ten_hours) + end + + test "returned expiration duration is capped at 2 weeks for users using clients", %{ bypass: bypass, account: account, provider: provider, @@ -2208,21 +2885,23 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :client, user_agent: user_agent, remote_ip: remote_ip} - assert {:ok, %Auth.Subject{} = subject} = sign_in(provider, payload, user_agent, remote_ip) - + assert {:ok, _token_identity, fragment} = sign_in(provider, nonce, payload, context) + assert {:ok, subject} = authenticate(nonce <> fragment, context) one_week = 7 * 24 * 60 * 60 assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week) end - test "returned expiration duration is capped at 1 week for account admin users", %{ + test "returned expiration duration is capped at 10 hours for users browser session", %{ bypass: bypass, account: account, provider: provider, user_agent: user_agent, remote_ip: remote_ip } do - actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + actor = Fixtures.Actors.create_actor(type: :account_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) token = @@ -2238,11 +2917,14 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" - assert {:ok, %Auth.Subject{} = subject} = sign_in(provider, payload, user_agent, remote_ip) + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - one_week = 7 * 24 * 60 * 60 - assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week - 60 * 60) + assert {:ok, _token_identity, fragment} = sign_in(provider, nonce, payload, context) + assert {:ok, subject} = authenticate(nonce <> fragment, context) + ten_hours = 10 * 60 * 60 + assert_datetime_diff(subject.expires_at, DateTime.utc_now(), ten_hours) end test "returns error when provider is disabled", %{ @@ -2255,7 +2937,6 @@ defmodule Domain.AuthTest do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) subject = Fixtures.Auth.create_subject(identity: identity) - {:ok, _provider} = disable_provider(provider, subject) {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) @@ -2264,9 +2945,12 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} + assert {:ok, _token_identity, _fragment} = sign_in(provider, nonce, payload, context) + {:ok, _provider} = disable_provider(provider, subject) + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end test "returns error when identity is disabled", %{ @@ -2279,7 +2963,6 @@ defmodule Domain.AuthTest do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) subject = Fixtures.Auth.create_subject(identity: identity) - {:ok, identity} = delete_identity(identity, subject) {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) @@ -2288,9 +2971,12 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} + assert {:ok, _token_identity, _fragment} = sign_in(provider, nonce, payload, context) + {:ok, _identity} = delete_identity(identity, subject) + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end test "returns error when actor is disabled", %{ @@ -2300,10 +2986,7 @@ defmodule Domain.AuthTest do user_agent: user_agent, remote_ip: remote_ip } do - actor = - Fixtures.Actors.create_actor(type: :account_admin_user, account: account) - |> Fixtures.Actors.disable() - + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) @@ -2313,9 +2996,12 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} + assert {:ok, _token_identity, _fragment} = sign_in(provider, nonce, payload, context) + Fixtures.Actors.disable(actor) + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end test "returns error when actor is deleted", %{ @@ -2325,10 +3011,7 @@ defmodule Domain.AuthTest do user_agent: user_agent, remote_ip: remote_ip } do - actor = - Fixtures.Actors.create_actor(type: :account_admin_user, account: account) - |> Fixtures.Actors.delete() - + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) @@ -2338,9 +3021,12 @@ defmodule Domain.AuthTest do code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() redirect_uri = "https://example.com/" payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} + assert {:ok, _token_identity, _fragment} = sign_in(provider, nonce, payload, context) + Fixtures.Actors.delete(actor) + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end test "returns error when provider is deleted", %{ @@ -2353,47 +3039,60 @@ defmodule Domain.AuthTest do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor) subject = Fixtures.Auth.create_subject(identity: identity) + + {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() + redirect_uri = "https://example.com/" + payload = {redirect_uri, code_verifier, "MyFakeCode"} + nonce = "nonce" + context = %Auth.Context{type: :browser, user_agent: user_agent, remote_ip: remote_ip} + + assert {:ok, _token_identity, _fragment} = sign_in(provider, nonce, payload, context) {:ok, _provider} = delete_provider(provider, subject) - - {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) - Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) - Mocks.OpenIDConnect.expect_userinfo(bypass) - - code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() - redirect_uri = "https://example.com/" - payload = {redirect_uri, code_verifier, "MyFakeCode"} - - assert sign_in(provider, payload, user_agent, remote_ip) == - {:error, :unauthorized} - end - - test "updates last signed in fields for identity on success", %{ - bypass: bypass, - account: account, - provider: provider, - user_agent: user_agent, - remote_ip: remote_ip - } do - identity = Fixtures.Auth.create_identity(account: account, provider: provider) - - {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) - Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) - Mocks.OpenIDConnect.expect_userinfo(bypass) - - code_verifier = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_verifier() - redirect_uri = "https://example.com/" - payload = {redirect_uri, code_verifier, "MyFakeCode"} - - assert {:ok, _subject} = sign_in(provider, payload, user_agent, remote_ip) - - assert updated_identity = Repo.one(Auth.Identity) - assert updated_identity.last_seen_at != identity.last_seen_at - assert updated_identity.last_seen_remote_ip != identity.last_seen_remote_ip - assert updated_identity.last_seen_user_agent != identity.last_seen_user_agent + assert sign_in(provider, nonce, payload, context) == {:error, :unauthorized} end end - describe "sign_in/2" do + describe "sign_out/2" do + test "redirects to post logout redirect url for OpenID Connect providers" do + account = Fixtures.Accounts.create_account() + + {provider, _bypass} = + Fixtures.Auth.start_and_create_openid_connect_provider(account: account) + + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + subject = Fixtures.Auth.create_subject(account: account, identity: identity) + + assert {:ok, %Auth.Identity{}, redirect_url} = sign_out(subject, "https://fz.d/sign_out") + + post_redirect_url = URI.encode_www_form("https://fz.d/sign_out") + + assert redirect_url =~ "https://example.com" + assert redirect_url =~ "id_token_hint=" + assert redirect_url =~ "client_id=#{provider.adapter_config["client_id"]}" + assert redirect_url =~ "post_logout_redirect_uri=#{post_redirect_url}" + + assert Repo.get(Tokens.Token, subject.token_id).deleted_at + end + + test "returns identity and url without changes for other providers" do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + account = Fixtures.Accounts.create_account() + provider = Fixtures.Auth.create_email_provider(account: account) + identity = Fixtures.Auth.create_identity(account: account, provider: provider) + subject = Fixtures.Auth.create_subject(account: account, identity: identity) + + assert {:ok, %Auth.Identity{}, "https://fz.d/sign_out"} = + sign_out(subject, "https://fz.d/sign_out") + + assert Repo.get(Tokens.Token, subject.token_id).deleted_at + end + end + + describe "create_service_account_token/3" do setup do account = Fixtures.Accounts.create_account() Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) @@ -2419,6 +3118,7 @@ defmodule Domain.AuthTest do user_agent: user_agent, remote_ip: remote_ip, context: %Auth.Context{ + type: :client, remote_ip: remote_ip, remote_ip_location_region: "UA", remote_ip_location_city: "Kyiv", @@ -2429,62 +3129,21 @@ defmodule Domain.AuthTest do } end - test "returns error when token is invalid", %{context: context} do - assert sign_in(Ecto.UUID.generate(), context) == - {:error, :unauthorized} - end - - test "returns subject on success for session token", %{ - subject: subject, - context: context - } do - {:ok, token} = create_session_token_from_subject(subject) - - assert {:ok, reconstructed_subject} = sign_in(token, context) - assert reconstructed_subject.identity.id == subject.identity.id - assert reconstructed_subject.actor.id == subject.actor.id - assert reconstructed_subject.account.id == subject.account.id - assert reconstructed_subject.permissions == subject.permissions - assert reconstructed_subject.context.remote_ip == subject.context.remote_ip - assert reconstructed_subject.context.user_agent == subject.context.user_agent - assert DateTime.diff(reconstructed_subject.expires_at, subject.expires_at) <= 1 - end - - test "returns subject on success for client token", %{ - subject: subject, - context: context - } do - {:ok, token} = create_client_token_from_subject(subject) - - # Client sessions are not binded to a specific user agent or remote ip - remote_ip = Domain.Fixture.unique_ipv4() - user_agent = context.user_agent <> "+b1" - context = %{context | remote_ip: remote_ip, user_agent: user_agent} - - assert {:ok, reconstructed_subject} = sign_in(token, context) - - assert reconstructed_subject.identity.id == subject.identity.id - assert reconstructed_subject.actor.id == subject.actor.id - assert reconstructed_subject.account.id == subject.account.id - assert reconstructed_subject.permissions == subject.permissions - assert reconstructed_subject.context != subject.context - assert reconstructed_subject.context.user_agent == user_agent - assert reconstructed_subject.context.remote_ip == remote_ip - assert DateTime.diff(reconstructed_subject.expires_at, subject.expires_at) <= 1 - end - - test "returns subject on success for service account token", %{ + test "returns valid client token for a given service account identity", %{ account: account, context: context, subject: subject } do - one_day = DateTime.utc_now() |> DateTime.add(1, :day) + one_day = DateTime.utc_now() |> DateTime.add(1, :day) |> DateTime.truncate(:second) provider = Fixtures.Auth.create_token_provider(account: account) + actor = Fixtures.Actors.create_actor(type: :service_account, account: account) + identity = Fixtures.Auth.create_identity( account: account, provider: provider, + actor: actor, user_agent: context.user_agent, remote_ip: context.remote_ip, provider_virtual_state: %{ @@ -2492,115 +3151,345 @@ defmodule Domain.AuthTest do } ) - {:ok, token} = create_access_token_for_identity(identity) + assert {:ok, encoded_token} = create_service_account_token(provider, identity, subject) - assert {:ok, reconstructed_subject} = sign_in(token, context) + assert {:ok, sa_subject} = authenticate(encoded_token, context) + assert sa_subject.account.id == account.id + assert sa_subject.actor.id == identity.actor_id + assert sa_subject.identity.id == identity.id + assert sa_subject.context.type == context.type + + assert token = Repo.get(Tokens.Token, sa_subject.token_id) + assert token.type == context.type + assert token.account_id == account.id + assert token.identity_id == identity.id + assert token.created_by == :identity + assert token.created_by_identity_id == subject.identity.id + assert token.created_by_user_agent == context.user_agent + assert token.created_by_remote_ip.address == context.remote_ip + assert sa_subject.permissions == fetch_type_permissions!(:service_account) + + assert sa_subject.expires_at == token.expires_at + assert DateTime.truncate(sa_subject.expires_at, :second) == one_day + end + end + + describe "authenticate/2" do + setup do + account = Fixtures.Accounts.create_account() + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + provider = Fixtures.Auth.create_email_provider(account: account) + user_agent = Fixtures.Auth.user_agent() + remote_ip = Fixtures.Auth.remote_ip() + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + + identity = + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + user_agent: user_agent, + remote_ip: remote_ip + ) + + browser_context = + Fixtures.Auth.build_context( + type: :browser, + user_agent: user_agent, + remote_ip: remote_ip + ) + + browser_subject = + Fixtures.Auth.create_subject( + account: account, + identity: identity, + context: browser_context + ) + + nonce = "nonce" + + {:ok, browser_token} = create_token(identity, browser_context, nonce, nil) + + browser_fragment = Tokens.encode_fragment!(browser_token) + + client_context = + Fixtures.Auth.build_context( + type: :client, + user_agent: user_agent, + remote_ip: remote_ip + ) + + client_subject = + Fixtures.Auth.create_subject( + account: account, + identity: identity, + context: client_context + ) + + {:ok, client_token} = create_token(identity, client_context, nonce, nil) + client_fragment = Tokens.encode_fragment!(client_token) + + %{ + account: account, + provider: provider, + actor: actor, + identity: identity, + user_agent: user_agent, + remote_ip: remote_ip, + nonce: nonce, + browser_context: browser_context, + browser_subject: browser_subject, + browser_token: browser_token, + browser_fragment: browser_fragment, + client_context: client_context, + client_subject: client_subject, + client_token: client_token, + client_fragment: client_fragment + } + end + + test "returns error when token is invalid", %{ + nonce: nonce, + browser_context: browser_context, + client_context: client_context + } do + assert authenticate(nonce <> ".foo", browser_context) == {:error, :unauthorized} + assert authenticate("foo", browser_context) == {:error, :unauthorized} + assert authenticate(nonce <> ".foo", client_context) == {:error, :unauthorized} + assert authenticate("foo", client_context) == {:error, :unauthorized} + end + + test "returns error when token is issued for a different context type", %{ + nonce: nonce, + browser_context: browser_context, + browser_fragment: browser_fragment, + client_context: client_context, + client_fragment: client_fragment + } do + assert authenticate(nonce <> client_fragment, browser_context) == {:error, :unauthorized} + assert authenticate(nonce <> browser_fragment, client_context) == {:error, :unauthorized} + end + + test "returns error when nonce is invalid", %{ + browser_context: browser_context, + browser_fragment: browser_fragment, + client_context: client_context, + client_fragment: client_fragment + } do + assert authenticate("foo" <> client_fragment, browser_context) == {:error, :unauthorized} + assert authenticate("foo" <> browser_fragment, client_context) == {:error, :unauthorized} + end + + test "returns subject for browser token", %{ + account: account, + actor: actor, + identity: identity, + nonce: nonce, + browser_context: context, + browser_token: token, + browser_fragment: fragment + } do + assert {:ok, reconstructed_subject} = authenticate(nonce <> fragment, context) assert reconstructed_subject.identity.id == identity.id - assert reconstructed_subject.actor.id == identity.actor_id - assert reconstructed_subject.account.id == identity.account_id - assert reconstructed_subject.permissions == subject.permissions - assert reconstructed_subject.context.remote_ip == subject.context.remote_ip - assert reconstructed_subject.context.user_agent == subject.context.user_agent - assert DateTime.diff(reconstructed_subject.expires_at, one_day) <= 1 + assert reconstructed_subject.actor.id == actor.id + assert reconstructed_subject.account.id == account.id + assert reconstructed_subject.permissions == fetch_type_permissions!(actor.type) + assert reconstructed_subject.context.remote_ip == context.remote_ip + assert reconstructed_subject.context.user_agent == context.user_agent + + assert reconstructed_subject.expires_at == token.expires_at + end + + test "returns an error when browser user agent is changed", %{ + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + context = %{context | user_agent: context.user_agent <> "+b1"} + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} + end + + test "returns an error when browser ip address is changed", %{ + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + context = %{context | remote_ip: Domain.Fixture.unique_ipv4()} + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} + end + + test "returns subject for client token", %{ + account: account, + actor: actor, + identity: identity, + nonce: nonce, + client_context: context, + client_token: token, + client_fragment: fragment + } do + assert {:ok, reconstructed_subject} = authenticate(nonce <> fragment, context) + assert reconstructed_subject.identity.id == identity.id + assert reconstructed_subject.actor.id == actor.id + assert reconstructed_subject.account.id == account.id + assert reconstructed_subject.permissions == fetch_type_permissions!(actor.type) + assert reconstructed_subject.context.remote_ip == context.remote_ip + assert reconstructed_subject.context.user_agent == context.user_agent + assert reconstructed_subject.expires_at == token.expires_at + end + + test "returns subject for client service account token", %{ + account: account, + client_context: context, + client_subject: subject + } do + one_day = DateTime.utc_now() |> DateTime.add(1, :day) + provider = Fixtures.Auth.create_token_provider(account: account) + + actor = Fixtures.Actors.create_actor(type: :service_account, account: account) + + identity = + Fixtures.Auth.create_identity( + account: account, + provider: provider, + actor: actor, + user_agent: context.user_agent, + remote_ip: context.remote_ip, + provider_virtual_state: %{ + "expires_at" => one_day + } + ) + + assert {:ok, encoded_token} = create_service_account_token(provider, identity, subject) + + assert {:ok, reconstructed_subject} = authenticate(encoded_token, context) + assert reconstructed_subject.identity.id == identity.id + assert reconstructed_subject.actor.id == actor.id + assert reconstructed_subject.account.id == account.id + assert reconstructed_subject.permissions != subject.permissions + assert reconstructed_subject.permissions == fetch_type_permissions!(:service_account) + assert reconstructed_subject.context.remote_ip == context.remote_ip + assert reconstructed_subject.context.user_agent == context.user_agent + + assert reconstructed_subject.expires_at == one_day + end + + test "client token is not bound to remote ip and user agent", %{ + nonce: nonce, + client_context: context, + client_fragment: fragment + } do + context = %{ + context + | user_agent: context.user_agent <> "+b1", + remote_ip: Domain.Fixture.unique_ipv4() + } + + assert {:ok, subject} = authenticate(nonce <> fragment, context) + assert subject.context.remote_ip == context.remote_ip + assert subject.context.user_agent == context.user_agent end test "updates last signed in fields for identity on success", %{ identity: identity, - subject: subject, - context: context + nonce: nonce, + browser_context: context, + browser_fragment: fragment } do - {:ok, token} = create_session_token_from_subject(subject) + assert {:ok, subject} = authenticate(nonce <> fragment, context) - assert {:ok, _subject} = sign_in(token, context) + assert subject.identity.last_seen_at != identity.last_seen_at + assert subject.identity.last_seen_remote_ip != identity.last_seen_remote_ip + assert subject.identity.last_seen_remote_ip.address == context.remote_ip - assert updated_identity = Repo.one(Auth.Identity) - assert updated_identity.last_seen_at != identity.last_seen_at - assert updated_identity.last_seen_remote_ip != identity.last_seen_remote_ip - assert updated_identity.last_seen_user_agent != identity.last_seen_user_agent + assert subject.identity.last_seen_remote_ip_location_region == + context.remote_ip_location_region + + assert subject.identity.last_seen_remote_ip_location_city == context.remote_ip_location_city + assert subject.identity.last_seen_remote_ip_location_lat == context.remote_ip_location_lat + assert subject.identity.last_seen_remote_ip_location_lon == context.remote_ip_location_lon + + assert subject.identity.last_seen_user_agent != identity.last_seen_user_agent + assert subject.identity.last_seen_user_agent == context.user_agent + + assert identity = Repo.get(Auth.Identity, subject.identity.id) + assert identity.last_seen_at == subject.identity.last_seen_at end - # XXX: Use different params to pin the session token on as these are likely to change - # over the lifetime of the session token. - # test "returns error when session token is created with a different remote ip", %{ - # subject: subject, - # user_agent: user_agent - # } do - # {:ok, token} = create_session_token_from_subject(subject) - # assert sign_in(token, user_agent, {127, 0, 0, 1}) == {:error, :unauthorized} - # end - # - # test "returns error when session token is created with a different user agent", %{ - # subject: subject, - # remote_ip: remote_ip - # } do - # user_agent = "iOS/12.6 (iPhone) connlib/0.7.412" - # {:ok, token} = create_session_token_from_subject(subject) - # assert sign_in(token, context) == {:error, :unauthorized} - # end - - test "returns error when token is created for a deleted identity", %{ + test "updates last signed in fields for token on success", %{ identity: identity, - subject: subject, - context: context + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + assert {:ok, subject} = authenticate(nonce <> fragment, context) + + assert token = Repo.get(Tokens.Token, subject.token_id) + assert token.last_seen_at != identity.last_seen_at + assert token.last_seen_remote_ip != identity.last_seen_remote_ip + assert token.last_seen_remote_ip.address == context.remote_ip + assert token.last_seen_remote_ip_location_region == context.remote_ip_location_region + assert token.last_seen_remote_ip_location_city == context.remote_ip_location_city + assert token.last_seen_remote_ip_location_lat == context.remote_ip_location_lat + assert token.last_seen_remote_ip_location_lon == context.remote_ip_location_lon + assert token.last_seen_user_agent != identity.last_seen_user_agent + assert token.last_seen_user_agent == context.user_agent + end + + test "returns error when token identity is deleted", %{ + identity: identity, + nonce: nonce, + browser_context: context, + browser_fragment: fragment, + browser_subject: subject } do {:ok, _identity} = delete_identity(identity, subject) - {:ok, token} = create_session_token_from_subject(subject) - assert sign_in(token, context) == {:error, :unauthorized} - end - end - - describe "sign_out/2" do - test "redirects to post logout redirect url for OpenID Connect providers" do - account = Fixtures.Accounts.create_account() - - {provider, _bypass} = - Fixtures.Auth.start_and_create_openid_connect_provider(account: account) - - identity = Fixtures.Auth.create_identity(account: account, provider: provider) - - assert {:ok, %Auth.Identity{}, redirect_url} = sign_out(identity, "https://fz.d/sign_out") - - post_redirect_url = URI.encode_www_form("https://fz.d/sign_out") - - assert redirect_url =~ "https://example.com" - assert redirect_url =~ "id_token_hint=" - assert redirect_url =~ "client_id=#{provider.adapter_config["client_id"]}" - assert redirect_url =~ "post_logout_redirect_uri=#{post_redirect_url}" + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} end - test "returns identity and url without changes for other providers" do - Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) - account = Fixtures.Accounts.create_account() - provider = Fixtures.Auth.create_email_provider(account: account) - identity = Fixtures.Auth.create_identity(account: account, provider: provider) + test "returns error when token identity actor is deleted", %{ + actor: actor, + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + Fixtures.Actors.delete(actor) - assert {:ok, %Auth.Identity{}, "https://fz.d/sign_out"} = - sign_out(identity, "https://fz.d/sign_out") + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} end - end - describe "create_session_token_from_subject/1" do - test "returns valid session token for a given subject" do - subject = Fixtures.Auth.create_subject() - assert {:ok, _token} = create_session_token_from_subject(subject) + test "returns error when token identity actor is disabled", %{ + actor: actor, + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + Fixtures.Actors.disable(actor) + + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} end - end - describe "create_client_token_from_subject/1" do - test "returns valid client token for a given subject" do - subject = Fixtures.Auth.create_subject() - assert {:ok, _token} = create_client_token_from_subject(subject) + test "returns error when token identity provider is deleted", %{ + provider: provider, + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + Fixtures.Auth.delete_provider(provider) + + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} end - end - describe "fetch_session_token_expires_at/2" do - test "returns datetime when the token expires" do - subject = Fixtures.Auth.create_subject() - {:ok, token} = create_session_token_from_subject(subject) + test "returns error when token identity provider is disabled", %{ + provider: provider, + nonce: nonce, + browser_context: context, + browser_fragment: fragment + } do + Fixtures.Auth.disable_provider(provider) - assert {:ok, expires_at} = fetch_session_token_expires_at(token) - assert_datetime_diff(expires_at, DateTime.utc_now(), 60) + assert authenticate(nonce <> fragment, context) == {:error, :unauthorized} end end @@ -2718,12 +3607,23 @@ defmodule Domain.AuthTest do required_permissions = [Authorizer.manage_providers_permission()] assert ensure_has_permissions(subject, required_permissions) == - {:error, {:unauthorized, [missing_permissions: required_permissions]}} + {:error, + {:unauthorized, + reason: :missing_permissions, missing_permissions: required_permissions}} required_permissions = [{:one_of, [Authorizer.manage_providers_permission()]}] assert ensure_has_permissions(subject, required_permissions) == - {:error, {:unauthorized, [missing_permissions: required_permissions]}} + {:error, + {:unauthorized, + reason: :missing_permissions, missing_permissions: required_permissions}} + end + + test "returns error when subject is expired", %{subject: subject} do + subject = %{subject | expires_at: DateTime.utc_now() |> DateTime.add(-1, :second)} + + assert ensure_has_permissions(subject, []) == + {:error, {:unauthorized, reason: :subject_expired}} end test "returns ok when subject has given permissions", %{ @@ -2744,6 +3644,24 @@ defmodule Domain.AuthTest do end end + describe "can_grant_role?/2" do + test "returns true if granted role requires a subset of permissions of the subject" do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + assert can_grant_role?(subject, :account_admin_user) + end + + test "returns false when granted role requires more permissions than the subject" do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + refute can_grant_role?(subject, :account_admin_user) + end + end + defp allow_child_sandbox_access(parent_pid) do Ecto.Adapters.SQL.Sandbox.allow(Repo, parent_pid, self()) # Allow is async call we need to break current process execution diff --git a/elixir/apps/domain/test/domain/clients_test.exs b/elixir/apps/domain/test/domain/clients_test.exs index 8aa3b426b..d262b5800 100644 --- a/elixir/apps/domain/test/domain/clients_test.exs +++ b/elixir/apps/domain/test/domain/clients_test.exs @@ -135,14 +135,13 @@ defmodule Domain.ClientsTest do assert fetch_client_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Clients.Authorizer.manage_clients_permission(), - Clients.Authorizer.manage_own_clients_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Clients.Authorizer.manage_clients_permission(), + Clients.Authorizer.manage_own_clients_permission() + ]} ]}} end end @@ -212,14 +211,13 @@ defmodule Domain.ClientsTest do assert list_clients(subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Clients.Authorizer.manage_clients_permission(), - Clients.Authorizer.manage_own_clients_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Clients.Authorizer.manage_clients_permission(), + Clients.Authorizer.manage_own_clients_permission() + ]} ]}} end end @@ -294,6 +292,7 @@ defmodule Domain.ClientsTest do {:error, {:unauthorized, [ + reason: :missing_permissions, missing_permissions: [ {:one_of, [ @@ -506,7 +505,8 @@ defmodule Domain.ClientsTest do assert upsert_client(%{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]}} end end @@ -554,7 +554,8 @@ defmodule Domain.ClientsTest do assert update_client(client, attrs, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_clients_permission()]}} end test "does not allow admin actor to update clients in other accounts", %{ @@ -618,14 +619,16 @@ defmodule Domain.ClientsTest do assert update_client(client, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]}} client = Fixtures.Clients.create_client() assert update_client(client, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_clients_permission()]}} end end @@ -683,14 +686,16 @@ defmodule Domain.ClientsTest do assert delete_client(client, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_clients_permission()]}} client = Fixtures.Clients.create_client(account: account) assert delete_client(client, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_clients_permission()]}} assert Repo.aggregate(Clients.Client, :count) == 2 end @@ -706,35 +711,56 @@ defmodule Domain.ClientsTest do assert delete_client(client, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]}} client = Fixtures.Clients.create_client() assert delete_client(client, subject) == {:error, {:unauthorized, - [missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_clients_permission()]}} end end - describe "delete_actor_clients/1" do - test "removes all clients that belong to an actor" do - actor = Fixtures.Actors.create_actor() + describe "delete_actor_clients/2" do + test "removes all clients that belong to an actor", %{ + account: account, + admin_subject: subject + } do + actor = Fixtures.Actors.create_actor(account: account) Fixtures.Clients.create_client(actor: actor) Fixtures.Clients.create_client(actor: actor) Fixtures.Clients.create_client(actor: actor) - assert Repo.aggregate(Clients.Client.Query.not_deleted(), :count) == 3 - assert delete_actor_clients(actor) == :ok - assert Repo.aggregate(Clients.Client.Query.not_deleted(), :count) == 0 + assert Repo.aggregate(Clients.Client.Query.by_actor_id(actor.id), :count) == 3 + assert delete_actor_clients(actor, subject) == :ok + assert Repo.aggregate(Clients.Client.Query.by_actor_id(actor.id), :count) == 0 end - test "does not remove clients that belong to another actor" do - actor = Fixtures.Actors.create_actor() + test "does not remove clients that belong to another actor", %{ + account: account, + admin_subject: subject + } do + actor = Fixtures.Actors.create_actor(account: account) Fixtures.Clients.create_client() - assert delete_actor_clients(actor) == :ok + assert delete_actor_clients(actor, subject) == :ok assert Repo.aggregate(Clients.Client.Query.all(), :count) == 1 end + + test "doesn't allow regular user to delete other user's clients", %{ + unprivileged_subject: subject + } do + actor = Fixtures.Actors.create_actor() + Fixtures.Clients.create_client(actor: actor) + + assert delete_actor_clients(actor, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Clients.Authorizer.manage_clients_permission()]}} + end end end diff --git a/elixir/apps/domain/test/domain/config_test.exs b/elixir/apps/domain/test/domain/config_test.exs index c801166a3..e56bc46c0 100644 --- a/elixir/apps/domain/test/domain/config_test.exs +++ b/elixir/apps/domain/test/domain/config_test.exs @@ -424,7 +424,9 @@ defmodule Domain.ConfigTest do assert fetch_account_config(subject) == {:error, - {:unauthorized, [missing_permissions: [Config.Authorizer.manage_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Config.Authorizer.manage_permission()]}} end end @@ -454,7 +456,9 @@ defmodule Domain.ConfigTest do assert update_config(config, %{}, subject) == {:error, - {:unauthorized, [missing_permissions: [Config.Authorizer.manage_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Config.Authorizer.manage_permission()]}} end end diff --git a/elixir/apps/domain/test/domain/crypto_test.exs b/elixir/apps/domain/test/domain/crypto_test.exs index 91dabe187..ca27e3854 100644 --- a/elixir/apps/domain/test/domain/crypto_test.exs +++ b/elixir/apps/domain/test/domain/crypto_test.exs @@ -48,4 +48,62 @@ defmodule Domain.CryptoTest do end end end + + describe "hash/2" do + test "raises an error when secret is an empty string" do + assert_raise FunctionClauseError, fn -> + hash(:argon2, "") + end + + assert_raise FunctionClauseError, fn -> + hash(:sha, "") + end + end + + test "raises an error when secret is not a binary" do + assert_raise FunctionClauseError, fn -> + hash(:argon2, 1) + end + + assert_raise FunctionClauseError, fn -> + hash(:sha, 1) + end + end + end + + describe "equal?/3" do + test "returns false for empty strings" do + refute equal?(:argon2, "a", "") + refute equal?(:argon2, "", "a") + refute equal?(:sha, "a", "") + refute equal?(:sha, "", "a") + refute equal?(:sha3_256, "a", "") + refute equal?(:sha3_256, "", "a") + end + + test "returns false for nils" do + refute equal?(:argon2, nil, "") + refute equal?(:argon2, "", nil) + refute equal?(:sha, nil, "") + refute equal?(:sha, "", nil) + refute equal?(:sha3_256, nil, "") + refute equal?(:sha3_256, "", nil) + end + end + + describe "hash/2 and equal?/3" do + test "generates a valid hash of a given value" do + for algo <- [:sha, :sha3_256, :argon2, :blake2b], value <- ["foo", random_token()] do + hash = hash(algo, value) + + assert is_binary(hash) + assert hash != value + + assert equal?(algo, value, hash) + refute equal?(algo, random_token(), hash) + refute equal?(algo, "", hash) + refute equal?(algo, nil, hash) + end + end + end end diff --git a/elixir/apps/domain/test/domain/flows_test.exs b/elixir/apps/domain/test/domain/flows_test.exs index e52c1663e..448af8547 100644 --- a/elixir/apps/domain/test/domain/flows_test.exs +++ b/elixir/apps/domain/test/domain/flows_test.exs @@ -152,10 +152,9 @@ defmodule Domain.FlowsTest do assert authorize_flow(client, gateway, resource.id, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - Flows.Authorizer.create_flows_permission() - ] + reason: :missing_permissions, + missing_permissions: [ + Flows.Authorizer.create_flows_permission() ]}} subject = Fixtures.Auth.add_permission(subject, Flows.Authorizer.create_flows_permission()) @@ -163,10 +162,9 @@ defmodule Domain.FlowsTest do assert authorize_flow(client, gateway, resource.id, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - Domain.Resources.Authorizer.view_available_resources_permission() - ] + reason: :missing_permissions, + missing_permissions: [ + Domain.Resources.Authorizer.view_available_resources_permission() ]}} end @@ -228,7 +226,9 @@ defmodule Domain.FlowsTest do assert fetch_flow_by_id(Ecto.UUID.generate(), subject) == {:error, - {:unauthorized, [missing_permissions: [Authorizer.view_flows_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Authorizer.view_flows_permission()]}} end test "associations are preloaded when opts given", %{ @@ -358,7 +358,9 @@ defmodule Domain.FlowsTest do expected_error = {:error, - {:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Flows.Authorizer.view_flows_permission()]}} assert list_flows_for(policy, subject) == expected_error assert list_flows_for(resource, subject) == expected_error @@ -587,11 +589,15 @@ defmodule Domain.FlowsTest do assert list_flow_activities_for(account, ended_after, started_before, subject) == {:error, - {:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Flows.Authorizer.view_flows_permission()]}} assert list_flow_activities_for(flow, ended_after, started_before, subject) == {:error, - {:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}} + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Flows.Authorizer.view_flows_permission()]}} end end end diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 43730a5e0..c1c653152 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -69,7 +69,8 @@ defmodule Domain.GatewaysTest do assert fetch_group_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end @@ -139,7 +140,8 @@ defmodule Domain.GatewaysTest do assert list_groups(subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end @@ -219,7 +221,8 @@ defmodule Domain.GatewaysTest do assert create_group(%{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end @@ -294,7 +297,8 @@ defmodule Domain.GatewaysTest do assert update_group(group, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end @@ -340,7 +344,8 @@ defmodule Domain.GatewaysTest do assert delete_group(group, subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end @@ -425,14 +430,13 @@ defmodule Domain.GatewaysTest do assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Gateways.Authorizer.manage_gateways_permission(), - Gateways.Authorizer.connect_gateways_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Gateways.Authorizer.manage_gateways_permission(), + Gateways.Authorizer.connect_gateways_permission() + ]} ]}} end @@ -489,7 +493,8 @@ defmodule Domain.GatewaysTest do assert list_gateways(subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end # TODO: add a test that soft-deleted assocs are not preloaded @@ -674,7 +679,7 @@ defmodule Domain.GatewaysTest do assert Repo.aggregate(Gateways.Gateway, :count, :id) == 1 - assert updated_gateway.name + assert updated_gateway.name != gateway.name assert updated_gateway.last_seen_remote_ip.address == attrs.last_seen_remote_ip assert updated_gateway.last_seen_remote_ip != gateway.last_seen_remote_ip assert updated_gateway.last_seen_user_agent == attrs.last_seen_user_agent @@ -815,7 +820,8 @@ defmodule Domain.GatewaysTest do assert update_gateway(gateway, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end @@ -845,7 +851,8 @@ defmodule Domain.GatewaysTest do assert delete_gateway(gateway, subject) == {:error, {:unauthorized, - [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]}} end end diff --git a/elixir/apps/domain/test/domain/policies_test.exs b/elixir/apps/domain/test/domain/policies_test.exs index 1c1f10516..ada7d10a3 100644 --- a/elixir/apps/domain/test/domain/policies_test.exs +++ b/elixir/apps/domain/test/domain/policies_test.exs @@ -54,14 +54,13 @@ defmodule Domain.PoliciesTest do assert fetch_policy_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Policies.Authorizer.manage_policies_permission(), - Policies.Authorizer.view_available_policies_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Policies.Authorizer.manage_policies_permission(), + Policies.Authorizer.view_available_policies_permission() + ]} ]}} end @@ -165,14 +164,13 @@ defmodule Domain.PoliciesTest do assert list_policies(subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Policies.Authorizer.manage_policies_permission(), - Policies.Authorizer.view_available_policies_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Policies.Authorizer.manage_policies_permission(), + Policies.Authorizer.view_available_policies_permission() + ]} ]}} end end @@ -203,10 +201,9 @@ defmodule Domain.PoliciesTest do assert create_policy(%{}, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, [Policies.Authorizer.manage_policies_permission()]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, [Policies.Authorizer.manage_policies_permission()]} ]}} end @@ -319,10 +316,9 @@ defmodule Domain.PoliciesTest do assert update_policy(policy, attrs, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, [Policies.Authorizer.manage_policies_permission()]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, [Policies.Authorizer.manage_policies_permission()]} ]}} end @@ -394,7 +390,8 @@ defmodule Domain.PoliciesTest do assert disable_policy(policy, subject) == {:error, {:unauthorized, - [missing_permissions: [Policies.Authorizer.manage_policies_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Policies.Authorizer.manage_policies_permission()]}} end end @@ -447,7 +444,8 @@ defmodule Domain.PoliciesTest do assert enable_policy(policy, subject) == {:error, {:unauthorized, - [missing_permissions: [Policies.Authorizer.manage_policies_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Policies.Authorizer.manage_policies_permission()]}} end end @@ -476,10 +474,9 @@ defmodule Domain.PoliciesTest do assert delete_policy(policy, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, [Policies.Authorizer.manage_policies_permission()]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, [Policies.Authorizer.manage_policies_permission()]} ]}} end diff --git a/elixir/apps/domain/test/domain/relays_test.exs b/elixir/apps/domain/test/domain/relays_test.exs index e2d0eb483..e665481a6 100644 --- a/elixir/apps/domain/test/domain/relays_test.exs +++ b/elixir/apps/domain/test/domain/relays_test.exs @@ -77,7 +77,8 @@ defmodule Domain.RelaysTest do assert fetch_group_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -129,7 +130,8 @@ defmodule Domain.RelaysTest do assert list_groups(subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -191,7 +193,8 @@ defmodule Domain.RelaysTest do assert create_group(%{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -311,7 +314,8 @@ defmodule Domain.RelaysTest do assert update_group(group, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -362,7 +366,8 @@ defmodule Domain.RelaysTest do assert delete_group(group, subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -447,7 +452,8 @@ defmodule Domain.RelaysTest do assert fetch_relay_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -494,7 +500,8 @@ defmodule Domain.RelaysTest do assert list_relays(subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end @@ -804,7 +811,8 @@ defmodule Domain.RelaysTest do assert delete_relay(relay, subject) == {:error, {:unauthorized, - [missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Relays.Authorizer.manage_relays_permission()]}} end end diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index 413bfc05e..1f193de55 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -80,14 +80,13 @@ defmodule Domain.ResourcesTest do assert fetch_resource_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Resources.Authorizer.manage_resources_permission(), - Resources.Authorizer.view_available_resources_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} ]}} end @@ -307,11 +306,8 @@ defmodule Domain.ResourcesTest do assert fetch_and_authorize_resource_by_id(Ecto.UUID.generate(), subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - Resources.Authorizer.view_available_resources_permission() - ] - ]}} + reason: :missing_permissions, + missing_permissions: [Resources.Authorizer.view_available_resources_permission()]}} end test "associations are preloaded when opts given", %{ @@ -483,11 +479,8 @@ defmodule Domain.ResourcesTest do assert list_authorized_resources(subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - Resources.Authorizer.view_available_resources_permission() - ] - ]}} + reason: :missing_permissions, + missing_permissions: [Resources.Authorizer.view_available_resources_permission()]}} end end @@ -533,10 +526,9 @@ defmodule Domain.ResourcesTest do assert list_resources(subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - Resources.Authorizer.manage_resources_permission() - ] + reason: :missing_permissions, + missing_permissions: [ + Resources.Authorizer.manage_resources_permission() ]}} end end @@ -617,14 +609,13 @@ defmodule Domain.ResourcesTest do assert list_resources_for_gateway(gateway, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Resources.Authorizer.manage_resources_permission(), - Resources.Authorizer.view_available_resources_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} ]}} end end @@ -743,7 +734,8 @@ defmodule Domain.ResourcesTest do assert peek_resource_actor_groups([], 3, subject) == {:error, {:unauthorized, - [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Resources.Authorizer.manage_resources_permission()]}} end end @@ -813,14 +805,13 @@ defmodule Domain.ResourcesTest do assert count_resources_for_gateway(gateway, subject) == {:error, {:unauthorized, - [ - missing_permissions: [ - {:one_of, - [ - Resources.Authorizer.manage_resources_permission(), - Resources.Authorizer.view_available_resources_permission() - ]} - ] + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} ]}} end end @@ -985,7 +976,8 @@ defmodule Domain.ResourcesTest do assert create_resource(%{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Resources.Authorizer.manage_resources_permission()]}} end end @@ -1078,7 +1070,8 @@ defmodule Domain.ResourcesTest do assert update_resource(resource, %{}, subject) == {:error, {:unauthorized, - [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Resources.Authorizer.manage_resources_permission()]}} end end @@ -1116,7 +1109,8 @@ defmodule Domain.ResourcesTest do assert delete_resource(resource, subject) == {:error, {:unauthorized, - [missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}} + reason: :missing_permissions, + missing_permissions: [Resources.Authorizer.manage_resources_permission()]}} end end diff --git a/elixir/apps/domain/test/domain/tokens_test.exs b/elixir/apps/domain/test/domain/tokens_test.exs new file mode 100644 index 000000000..e7f70ffdc --- /dev/null +++ b/elixir/apps/domain/test/domain/tokens_test.exs @@ -0,0 +1,558 @@ +defmodule Domain.TokensTest do + use Domain.DataCase, async: true + import Domain.Tokens + alias Domain.Tokens + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(account: account, actor: actor, identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + describe "fetch_token_by_id/2" do + test "returns error when subject does not have required permissions", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert fetch_token_by_id(Ecto.UUID.generate(), subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Tokens.Authorizer.manage_tokens_permission(), + Tokens.Authorizer.manage_own_tokens_permission() + ]} + ]}} + end + + test "returns error when token is not found", %{subject: subject} do + assert fetch_token_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found} + assert fetch_token_by_id("foo", subject) == {:error, :not_found} + end + + test "returns token for admin user", %{account: account, subject: subject} do + token = Fixtures.Tokens.create_token(account: account) + assert {:ok, _token} = fetch_token_by_id(token.id, subject) + end + + test "does not return other user tokens for non-admin users", %{account: account} do + actor = Fixtures.Actors.create_actor(type: :account_user, account: account) + subject = Fixtures.Auth.create_subject(account: account, actor: actor) + + token = Fixtures.Tokens.create_token(account: account) + assert fetch_token_by_id(token.id, subject) == {:error, :not_found} + end + end + + describe "list_tokens_for/1" do + test "returns current subject's tokens", %{ + account: account, + identity: identity, + subject: subject + } do + token = Fixtures.Tokens.create_token(account: account, identity: identity) + Fixtures.Tokens.create_token(account: account) + Fixtures.Tokens.create_token() + + assert {:ok, tokens} = list_tokens_for(subject) + token_ids = Enum.map(tokens, & &1.id) + assert token.id in token_ids + assert subject.token_id in token_ids + assert length(tokens) == 2 + end + + test "returns error when subject does not have required permissions", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert list_tokens_for(subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Tokens.Authorizer.manage_own_tokens_permission()]}} + end + end + + describe "list_tokens_for/2" do + test "returns tokens of a given actor", %{ + account: account, + subject: subject + } do + actor = Fixtures.Actors.create_actor(account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + token = Fixtures.Tokens.create_token(account: account, identity: identity) + + Fixtures.Tokens.create_token(account: account) + Fixtures.Tokens.create_token() + + assert {:ok, [fetched_token]} = list_tokens_for(actor, subject) + assert fetched_token.id == token.id + end + + test "returns error when subject does not have required permissions", %{ + actor: actor, + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert list_tokens_for(actor, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Tokens.Authorizer.manage_tokens_permission()]}} + end + end + + describe "create_token/2" do + test "returns errors on missing required attrs" do + assert {:error, changeset} = create_token(%{}) + + assert errors_on(changeset) == %{ + type: ["can't be blank"], + account_id: ["can't be blank"], + expires_at: ["can't be blank"], + secret_fragment: ["can't be blank"], + secret_hash: ["can't be blank"], + created_by_remote_ip: ["can't be blank"], + created_by_user_agent: ["can't be blank"] + } + end + + test "returns errors on invalid attrs" do + attrs = %{ + type: :relay, + secret_nonce: -1, + secret_fragment: -1, + expires_at: DateTime.utc_now(), + created_by_user_agent: -1, + created_by_remote_ip: -1, + account_id: Ecto.UUID.generate() + } + + assert {:error, changeset} = create_token(attrs) + + assert %{ + type: ["is invalid"], + expires_at: ["must be greater than" <> _], + secret_nonce: ["is invalid"], + secret_fragment: ["is invalid"], + secret_hash: ["can't be blank"], + created_by_remote_ip: ["is invalid"], + created_by_user_agent: ["is invalid"] + } = errors_on(changeset) + end + + test "inserts a token", %{account: account, identity: identity} do + type = :email + nonce = "nonce" + fragment = Domain.Crypto.random_token(32) + expires_at = DateTime.utc_now() |> DateTime.add(1, :day) + user_agent = Fixtures.Tokens.user_agent() + remote_ip = Fixtures.Tokens.remote_ip() + + attrs = %{ + type: type, + account_id: account.id, + identity_id: identity.id, + secret_nonce: nonce, + secret_fragment: fragment, + expires_at: expires_at, + created_by_user_agent: user_agent, + created_by_remote_ip: remote_ip + } + + assert {:ok, %Tokens.Token{} = token} = create_token(attrs) + + assert token.type == type + assert token.expires_at == expires_at + assert token.created_by_user_agent == user_agent + assert token.created_by_remote_ip.address == remote_ip + + refute token.secret_nonce + assert token.secret_fragment == fragment + assert token.secret_salt + assert token.secret_hash + + assert token.account_id == account.id + end + end + + describe "create_token/3" do + test "returns errors on missing required attrs", %{subject: subject} do + assert {:error, changeset} = create_token(%{}, subject) + + assert errors_on(changeset) == %{ + type: ["can't be blank"], + expires_at: ["can't be blank"], + secret_fragment: ["can't be blank"], + secret_hash: ["can't be blank"], + created_by_remote_ip: ["can't be blank"], + created_by_user_agent: ["can't be blank"] + } + end + + test "returns errors on invalid attrs", %{subject: subject} do + attrs = %{ + type: -1, + secret_nonce: "x.o", + secret_fragment: -1, + expires_at: DateTime.utc_now(), + created_by_user_agent: -1, + created_by_remote_ip: -1 + } + + assert {:error, changeset} = create_token(attrs, subject) + + assert %{ + type: ["is invalid"], + expires_at: ["must be greater than" <> _], + secret_nonce: ["has invalid format"], + secret_fragment: ["is invalid"], + secret_hash: ["can't be blank"], + created_by_remote_ip: ["is invalid"], + created_by_user_agent: ["is invalid"] + } = errors_on(changeset) + end + + test "inserts a token", %{account: account, subject: subject} do + type = :client + nonce = "nonce" + fragment = Domain.Crypto.random_token(32) + expires_at = DateTime.utc_now() |> DateTime.add(1, :day) + user_agent = Fixtures.Tokens.user_agent() + remote_ip = Fixtures.Tokens.remote_ip() + + attrs = %{ + type: type, + secret_nonce: nonce, + secret_fragment: fragment, + identity_id: subject.identity.id, + expires_at: expires_at, + created_by_user_agent: user_agent, + created_by_remote_ip: remote_ip + } + + assert {:ok, %Tokens.Token{} = token} = create_token(attrs, subject) + + assert token.type == type + assert token.expires_at == expires_at + assert token.created_by_user_agent == user_agent + assert token.created_by_remote_ip.address == remote_ip + + assert token.secret_fragment == fragment + refute token.secret_nonce + assert token.secret_salt + assert token.secret_hash + + assert Domain.Crypto.equal?( + :sha3_256, + nonce <> fragment <> token.secret_salt, + token.secret_hash + ) + + assert token.account_id == account.id + end + end + + describe "use_token/4" do + test "returns token when nonce, context and secret are valid", %{account: account} do + nonce = "nonce" + token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce) + context = Fixtures.Auth.build_context(type: token.type) + encoded_fragment = encode_fragment!(token) + + assert {:ok, used_token} = use_token(nonce <> encoded_fragment, context) + assert used_token.account_id == account.id + assert used_token.id == token.id + end + + test "updates last seen fields when token is used", %{account: account} do + nonce = "nonce" + token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce) + context = Fixtures.Auth.build_context(type: token.type) + encoded_fragment = encode_fragment!(token) + + assert {:ok, token} = use_token(nonce <> encoded_fragment, context) + + assert token.last_seen_user_agent == context.user_agent + assert token.last_seen_remote_ip.address == context.remote_ip + assert token.last_seen_remote_ip_location_region == context.remote_ip_location_region + assert token.last_seen_remote_ip_location_city == context.remote_ip_location_city + assert token.last_seen_remote_ip_location_lat == context.remote_ip_location_lat + assert token.last_seen_remote_ip_location_lon == context.remote_ip_location_lon + assert token.last_seen_at + end + + test "returns error when secret is invalid", %{account: account} do + nonce = "nonce" + token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce) + context = Fixtures.Auth.build_context(type: token.type) + encoded_fragment = encode_fragment!(%{token | secret_fragment: "bar"}) + + assert use_token(nonce <> encoded_fragment, context) == + {:error, :invalid_or_expired_token} + end + + test "returns error when nonce is invalid", %{account: account} do + token = Fixtures.Tokens.create_token(account: account) + context = Fixtures.Auth.build_context(type: token.type) + encoded_fragment = encode_fragment!(token) + + assert use_token("foo" <> encoded_fragment, context) == + {:error, :invalid_or_expired_token} + end + + test "returns error when signed token is invalid", %{account: account} do + token = Fixtures.Tokens.create_token(account: account) + context = Fixtures.Auth.build_context(type: token.type) + + assert use_token("nonce.bar", context) == {:error, :invalid_or_expired_token} + assert use_token("bar", context) == {:error, :invalid_or_expired_token} + assert use_token("", context) == {:error, :invalid_or_expired_token} + end + + test "returns error when type is invalid", %{account: account} do + nonce = "nonce" + token = Fixtures.Tokens.create_token(account: account, secret_nonce: nonce) + context = Fixtures.Auth.build_context(type: :other) + encoded_fragment = encode_fragment!(token) + + assert use_token(nonce <> encoded_fragment, context) == + {:error, :invalid_or_expired_token} + end + end + + describe "update_token/2" do + setup %{account: account} do + token = Fixtures.Tokens.create_token(account: account) + + %{token: token} + end + + test "no-op on empty attrs", %{token: token} do + assert {:ok, refreshed_token} = update_token(token, %{}) + assert refreshed_token.expires_at == token.expires_at + end + + test "returns errors on invalid attrs", %{token: token} do + attrs = %{ + expires_at: DateTime.utc_now() + } + + assert {:error, changeset} = update_token(token, attrs) + + assert %{ + expires_at: ["must be greater than" <> _] + } = errors_on(changeset) + end + + test "updates token expiration", %{token: token} do + attrs = %{ + expires_at: DateTime.utc_now() |> DateTime.add(1, :day) + } + + assert {:ok, token} = update_token(token, attrs) + assert token == %{token | expires_at: attrs.expires_at} + end + + test "does not extend expiration of expired tokens", %{token: token} do + token = Fixtures.Tokens.expire_token(token) + assert update_token(token, %{}) == {:error, :not_found} + end + + test "does not extend expiration of deleted tokens", %{token: token} do + token = Fixtures.Tokens.delete_token(token) + assert update_token(token, %{}) == {:error, :not_found} + end + end + + describe "delete_token/2" do + test "admin can delete any account token", %{account: account, subject: subject} do + token = Fixtures.Tokens.create_token(account: account) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert {:ok, token} = delete_token(token, subject) + + assert token.deleted_at + assert_receive "disconnect" + end + + test "user can delete own token", %{account: account, identity: identity, subject: subject} do + token = Fixtures.Tokens.create_token(account: account, identity: identity) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert {:ok, token} = delete_token(token, subject) + + assert token.deleted_at + assert_receive "disconnect" + end + + test "user can not delete other users token", %{ + account: account + } do + actor = Fixtures.Actors.create_actor(type: :account_user, account: account) + subject = Fixtures.Auth.create_subject(account: account, actor: actor) + + token = Fixtures.Tokens.create_token(account: account) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_token(token, subject) == {:error, :not_found} + + refute Repo.get(Tokens.Token, token.id).deleted_at + refute_receive "disconnect" + end + + test "does not delete tokens that belong to other accounts", %{ + subject: subject + } do + token = Fixtures.Tokens.create_token() + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_token(token, subject) == {:error, :not_found} + + refute Repo.get(Tokens.Token, token.id).deleted_at + refute_receive "disconnect" + end + + test "returns error when subject does not have required permissions", %{ + account: account, + subject: subject + } do + token = Fixtures.Tokens.create_token(account: account) + subject = Fixtures.Auth.remove_permissions(subject) + + assert delete_token(token, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [ + {:one_of, + [ + Tokens.Authorizer.manage_tokens_permission(), + Tokens.Authorizer.manage_own_tokens_permission() + ]} + ]}} + end + end + + describe "delete_tokens_for/1" do + test "deletes tokens for current subject", %{ + account: account, + identity: identity, + subject: subject + } do + token = Fixtures.Tokens.create_token(account: account, identity: identity) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_tokens_for(subject) == {:ok, 2} + + assert Repo.get(Tokens.Token, token.id).deleted_at + assert_receive "disconnect" + end + + test "does not delete tokens for other actors", %{account: account, subject: subject} do + token = Fixtures.Tokens.create_token(account: account) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_tokens_for(subject) == {:ok, 1} + + refute Repo.get(Tokens.Token, token.id).deleted_at + refute_receive "disconnect" + end + + test "returns error when subject does not have required permissions", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert delete_tokens_for(subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Tokens.Authorizer.manage_own_tokens_permission()]}} + end + end + + describe "delete_tokens_for/2" do + test "deletes tokens for given actor", %{account: account, subject: subject} do + actor = Fixtures.Actors.create_actor(account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + token = Fixtures.Tokens.create_token(account: account, identity: identity) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_tokens_for(actor, subject) == {:ok, 1} + + assert Repo.get(Tokens.Token, token.id).deleted_at + assert_receive "disconnect" + end + + test "returns error when subject does not have required permissions", %{ + actor: actor, + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert delete_tokens_for(actor, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Tokens.Authorizer.manage_tokens_permission()]}} + end + end + + describe "delete_expired_tokens/0" do + test "deletes expired tokens" do + token = + Fixtures.Tokens.create_token() + |> Fixtures.Tokens.expire_token() + + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_expired_tokens() == {:ok, 1} + + assert Repo.get(Tokens.Token, token.id).deleted_at + assert_receive "disconnect" + end + + test "does not delete non-expired tokens" do + in_one_minute = DateTime.utc_now() |> DateTime.add(1, :minute) + token = Fixtures.Tokens.create_token(expires_at: in_one_minute) + Phoenix.PubSub.subscribe(Domain.PubSub, "sessions:#{token.id}") + + assert delete_expired_tokens() == {:ok, 0} + + refute Repo.get(Tokens.Token, token.id).deleted_at + refute_receive "disconnect" + end + end + + describe "delete_token_by_id/1" do + test "marks token as deleted" do + token = Fixtures.Tokens.create_token() + assert delete_token_by_id(token.id) == {:ok, 1} + assert Repo.get(Tokens.Token, token.id).deleted_at + end + + test "returns error when token is already deleted" do + token = Fixtures.Tokens.create_token() + token = Fixtures.Tokens.delete_token(token) + assert delete_token_by_id(token.id) == {:ok, 0} + end + + test "returns error when token id is invalid" do + assert delete_token_by_id("foo") == {:ok, 0} + end + end +end diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index a26caae66..c667fa119 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -239,8 +239,6 @@ defmodule Domain.Fixtures.Auth do random_provider_identifier(provider) end) - {provider_state, attrs} = Map.pop(attrs, :provider_state) - {actor, attrs} = pop_assoc_fixture(attrs, :actor, fn assoc_attrs -> assoc_attrs @@ -257,19 +255,63 @@ defmodule Domain.Fixtures.Auth do {:ok, identity} = Auth.upsert_identity(actor, provider, attrs) - if provider_state do - identity - |> Ecto.Changeset.change(provider_state: provider_state) - |> Repo.update!() - else - identity - end + attrs = Map.take(attrs, [:provider_state, :created_by]) + + identity + |> Ecto.Changeset.change(attrs) + |> Repo.update!() end def delete_identity(identity) do update!(identity, deleted_at: DateTime.utc_now()) end + def build_context(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + {type, attrs} = Map.pop(attrs, :type, :browser) + + {user_agent, attrs} = + Map.pop_lazy(attrs, :user_agent, fn -> + user_agent() + end) + + {remote_ip, attrs} = + Map.pop_lazy(attrs, :remote_ip, fn -> + remote_ip() + end) + + {remote_ip_location_region, attrs} = + Map.pop_lazy(attrs, :remote_ip_location_region, fn -> + Enum.random(["US", "UA"]) + end) + + {remote_ip_location_city, attrs} = + Map.pop_lazy(attrs, :remote_ip_location_city, fn -> + Enum.random(["Odessa", "New York"]) + end) + + {remote_ip_location_lat, attrs} = + Map.pop_lazy(attrs, :remote_ip_location_city, fn -> + Enum.random([37.7758, 40.7128]) + end) + + {remote_ip_location_lon, _attrs} = + Map.pop_lazy(attrs, :remote_ip_location_city, fn -> + Enum.random([-122.4128, -74.0060]) + end) + + %Auth.Context{ + type: type, + remote_ip: remote_ip, + remote_ip_location_region: remote_ip_location_region, + remote_ip_location_city: remote_ip_location_city, + remote_ip_location_lat: remote_ip_location_lat, + remote_ip_location_lon: remote_ip_location_lon, + user_agent: user_agent + } + end + def create_subject(attrs \\ %{}) do attrs = Enum.into(attrs, %{}) @@ -340,46 +382,109 @@ defmodule Domain.Fixtures.Auth do DateTime.utc_now() |> DateTime.add(60, :second) end) - {user_agent, attrs} = - Map.pop_lazy(attrs, :user_agent, fn -> - user_agent() + {context, attrs} = + pop_assoc_fixture(attrs, :context, fn assoc_attrs -> + build_context(assoc_attrs) end) - {remote_ip, attrs} = - Map.pop_lazy(attrs, :remote_ip, fn -> - remote_ip() + {token, _attrs} = + pop_assoc_fixture(attrs, :token, fn assoc_attrs -> + assoc_attrs + |> Enum.into(%{ + account: account, + type: context.type, + secret_nonce: Domain.Crypto.random_token(32, encoder: :hex32), + identity_id: identity.id, + expires_at: expires_at + }) + |> Fixtures.Tokens.create_token() end) - {remote_ip_location_region, attrs} = - Map.pop_lazy(attrs, :remote_ip_location_region, fn -> - Enum.random(["US", "UA"]) + Auth.build_subject(token, identity, context) + end + + def create_and_encode_token(attrs) do + attrs = Enum.into(attrs, %{}) + + {account, attrs} = + pop_assoc_fixture(attrs, :account, fn assoc_attrs -> + relation = attrs[:provider] || attrs[:actor] || attrs[:identity] + + if not is_nil(relation) and is_struct(relation) do + Repo.get!(Domain.Accounts.Account, relation.account_id) + else + Fixtures.Accounts.create_account(assoc_attrs) + end end) - {remote_ip_location_city, attrs} = - Map.pop_lazy(attrs, :remote_ip_location_city, fn -> - Enum.random(["Odessa", "New York"]) + {provider, attrs} = + pop_assoc_fixture(attrs, :provider, fn assoc_attrs -> + relation = attrs[:identity] + + if not is_nil(relation) and is_struct(relation) do + Repo.get!(Domain.Auth.Provider, relation.provider_id) + else + {provider, _bypass} = + assoc_attrs + |> Enum.into(%{account: account}) + |> start_and_create_openid_connect_provider() + + provider + end end) - {remote_ip_location_lat, attrs} = - Map.pop_lazy(attrs, :remote_ip_location_city, fn -> - Enum.random([37.7758, 40.7128]) + {provider_identifier, attrs} = + Map.pop_lazy(attrs, :provider_identifier, fn -> + random_provider_identifier(provider) end) - {remote_ip_location_lon, _attrs} = - Map.pop_lazy(attrs, :remote_ip_location_city, fn -> - Enum.random([-122.4128, -74.0060]) + {actor, attrs} = + pop_assoc_fixture(attrs, :actor, fn assoc_attrs -> + relation = attrs[:identity] + + if not is_nil(relation) and is_struct(relation) do + Repo.get!(Domain.Actors.Actor, relation.actor_id) + else + assoc_attrs + |> Enum.into(%{ + type: :account_admin_user, + account: account, + provider: provider, + provider_identifier: provider_identifier + }) + |> Fixtures.Actors.create_actor() + end end) - context = %Auth.Context{ - remote_ip: remote_ip, - remote_ip_location_region: remote_ip_location_region, - remote_ip_location_city: remote_ip_location_city, - remote_ip_location_lat: remote_ip_location_lat, - remote_ip_location_lon: remote_ip_location_lon, - user_agent: user_agent - } + {identity, attrs} = + pop_assoc_fixture(attrs, :identity, fn assoc_attrs -> + assoc_attrs + |> Enum.into(%{ + actor: actor, + account: account, + provider: provider, + provider_identifier: provider_identifier + }) + |> create_identity() + end) - Auth.build_subject(identity, expires_at, context) + {expires_at, attrs} = + Map.pop_lazy(attrs, :expires_at, fn -> + DateTime.utc_now() |> DateTime.add(60, :second) + end) + + {context, attrs} = + pop_assoc_fixture(attrs, :context, fn assoc_attrs -> + build_context(assoc_attrs) + end) + + {nonce, _attrs} = + Map.pop_lazy(attrs, :nonce, fn -> + Domain.Crypto.random_token(32, encoder: :hex32) + end) + + {:ok, token} = Auth.create_token(identity, context, nonce, expires_at) + {token, nonce <> Domain.Tokens.encode_fragment!(token)} end def remove_permissions(%Auth.Subject{} = subject) do diff --git a/elixir/apps/domain/test/support/fixtures/tokens.ex b/elixir/apps/domain/test/support/fixtures/tokens.ex new file mode 100644 index 000000000..7fe2e9216 --- /dev/null +++ b/elixir/apps/domain/test/support/fixtures/tokens.ex @@ -0,0 +1,105 @@ +defmodule Domain.Fixtures.Tokens do + use Domain.Fixture + alias Domain.Tokens + + def remote_ip, do: Enum.random([unique_ipv4(), unique_ipv6()]) + def user_agent, do: "iOS/12.5 (iPhone; #{unique_integer()}) connlib/0.7.412" + + def token_attrs(attrs \\ %{}) do + type = :browser + nonce = Domain.Crypto.random_token(32, encoder: :hex32) + fragment = Domain.Crypto.random_token(32) + expires_at = DateTime.utc_now() |> DateTime.add(1, :day) + user_agent = Fixtures.Auth.user_agent() + remote_ip = Fixtures.Auth.remote_ip() + + Enum.into(attrs, %{ + type: type, + secret_nonce: nonce, + secret_fragment: fragment, + expires_at: expires_at, + created_by_user_agent: user_agent, + created_by_remote_ip: remote_ip + }) + end + + def create_email_token(attrs \\ %{}) do + attrs = attrs |> Enum.into(%{type: :email}) |> token_attrs() + + {account, attrs} = + pop_assoc_fixture(attrs, :account, fn assoc_attrs -> + Fixtures.Accounts.create_account(assoc_attrs) + end) + + {identity_id, attrs} = + pop_assoc_fixture_id(attrs, :identity, fn -> + Fixtures.Auth.create_identity(account: account) + end) + + attrs = Map.put(attrs, :identity_id, identity_id) + attrs = Map.put(attrs, :account_id, account.id) + + {:ok, token} = Domain.Tokens.create_token(attrs) + token + end + + def create_service_account_token(attrs \\ %{}) do + attrs = token_attrs(attrs) + + {account, attrs} = + pop_assoc_fixture(attrs, :account, fn assoc_attrs -> + Fixtures.Accounts.create_account(assoc_attrs) + end) + + {identity_id, attrs} = + pop_assoc_fixture_id(attrs, :identity, fn -> + Fixtures.Auth.create_identity(account: account) + end) + + {subject, attrs} = + pop_assoc_fixture(attrs, :subject, fn assoc_attrs -> + assoc_attrs + |> Enum.into(%{account: account, actor: [type: :account_admin_user]}) + |> Fixtures.Auth.create_subject() + end) + + attrs = Map.put(attrs, :identity_id, identity_id) + + {:ok, token} = Domain.Tokens.create_token(attrs, subject) + token + end + + def create_token(attrs \\ %{}) do + attrs = token_attrs(attrs) + + {account, attrs} = + pop_assoc_fixture(attrs, :account, fn assoc_attrs -> + Fixtures.Accounts.create_account(assoc_attrs) + end) + + {identity_id, attrs} = + pop_assoc_fixture_id(attrs, :identity, fn -> + Fixtures.Auth.create_identity(account: account) + end) + + attrs = Map.put(attrs, :identity_id, identity_id) + attrs = Map.put(attrs, :account_id, account.id) + + {:ok, token} = Domain.Tokens.create_token(attrs) + token + end + + def delete_token(token) do + token + |> Tokens.Token.Changeset.delete() + |> Domain.Repo.update!() + end + + def expire_token(token) do + one_minute_ago = DateTime.utc_now() |> DateTime.add(-1, :minute) + + token + |> Ecto.Changeset.change(expires_at: one_minute_ago) + |> Domain.Repo.update!() + end +end diff --git a/elixir/apps/web/lib/web/auth.ex b/elixir/apps/web/lib/web/auth.ex index a15c3807e..08a87234d 100644 --- a/elixir/apps/web/lib/web/auth.ex +++ b/elixir/apps/web/lib/web/auth.ex @@ -4,8 +4,8 @@ defmodule Web.Auth do # This is the cookie which will store recent account ids # that the user has signed in to. - @remember_me_cookie_name "fz_recent_account_ids" - @remember_me_cookie_options [ + @recent_accounts_cookie_name "fz_recent_account_ids" + @recent_accounts_cookie_options [ sign: true, max_age: 365 * 24 * 60 * 60, same_site: "Lax", @@ -14,106 +14,147 @@ defmodule Web.Auth do ] @remember_last_account_ids 5 - def signed_in_path(%Auth.Subject{actor: %{type: :account_admin_user}} = subject) do - ~p"/#{subject.account}/sites" + # Session is stored as a list in a cookie so we want to limit numbers + # of items in the list to avoid hitting cookie size limit. + @remember_last_sessions 10 + + # Session Management + + def put_account_session(%Plug.Conn{} = conn, context_type, account_id, encoded_fragment) + when context_type in [:browser, :client] do + session = {context_type, account_id, encoded_fragment} + + sessions = + Plug.Conn.get_session(conn, :sessions, []) + |> Enum.reject(fn {session_context_type, session_account_id, _encoded_fragment} -> + session_context_type == context_type and session_account_id == account_id + end) + + sessions = Enum.take(sessions ++ [session], -1 * @remember_last_sessions) + + Plug.Conn.put_session(conn, :sessions, sessions) end - def put_subject_in_session(conn, %Auth.Subject{} = subject) do - {:ok, session_token} = Auth.create_session_token_from_subject(subject) + # Signing In and Out - conn - |> Plug.Conn.put_session(:signed_in_at, DateTime.utc_now()) - |> Plug.Conn.put_session(:session_token, session_token) - |> Plug.Conn.put_session(:live_socket_id, "actors_sessions:#{subject.actor.id}") + @doc """ + Returns non-empty parameters that should be persisted during sign in flow. + """ + def take_sign_in_params(params) do + params + |> Map.take(["as", "state", "nonce", "redirect_to"]) + |> Map.reject(fn {_key, value} -> value in ["", nil] end) end @doc """ - Redirects the signed in user depending on the actor type. + Takes sign in parameters returned by `take_sign_in_params/1` and + returns the appropriate auth context type for them. + """ + def fetch_auth_context_type!(%{"as" => "client"}), do: :client + def fetch_auth_context_type!(_params), do: :browser - The account admin users are sent to authenticated home or a return path if it's stored in session. + def fetch_token_nonce!(%{"nonce" => nonce}), do: nonce + def fetch_token_nonce!(_params), do: nil - The account users are only expected to authenticate using client apps. - If the platform is known, we direct them to the application through a deep link or an app link; - if not, we guide them to the install instructions accompanied by an error message. + @doc """ + Persists the token in the session and redirects the user depending on the + auth context type. + + The browser users are sent to authenticated home or a return path if it's stored in params. + + The account users are only expected to authenticate using client apps and are redirected + to the deep link. """ - def signed_in_redirect( - %Plug.Conn{path_params: %{"account_id_or_slug" => account_id_or_slug}} = conn, - %Auth.Subject{account: %Accounts.Account{} = account}, - _client_platform, - _client_csrf_token - ) - when not is_nil(account_id_or_slug) and account_id_or_slug != account.id and - account_id_or_slug != account.slug do - conn - |> Web.Auth.renew_session() - |> Plug.Conn.delete_session(:user_return_to) - |> Phoenix.Controller.redirect(to: ~p"/#{account_id_or_slug}") - end + def signed_in( + %Plug.Conn{} = conn, + %Auth.Provider{} = provider, + %Auth.Identity{} = identity, + context, + encoded_fragment, + redirect_params + ) do + redirect_params = take_sign_in_params(redirect_params) + conn = prepend_recent_account_ids(conn, provider.account_id) - def signed_in_redirect( - conn, - %Auth.Subject{} = subject, - client_platform, - client_csrf_token - ) - when not is_nil(client_platform) and client_platform != "" do - platform_redirects = - Domain.Config.fetch_env!(:web, __MODULE__) - |> Keyword.fetch!(:platform_redirects) - - if redirects = Map.get(platform_redirects, client_platform) do - {:ok, client_token} = Auth.create_client_token_from_subject(subject) - - query = - %{ - client_auth_token: client_token, - client_csrf_token: client_csrf_token, - actor_name: subject.actor.name, - account_slug: subject.account.slug, - account_name: subject.account.name, - identity_provider_identifier: subject.identity.provider_identifier - } - |> Enum.reject(&is_nil(elem(&1, 1))) - |> URI.encode_query() - - redirect_method = Keyword.fetch!(redirects, :method) - redirect_dest = "#{Keyword.fetch!(redirects, :dest)}?#{query}" - - conn - |> Phoenix.Controller.redirect([{redirect_method, redirect_dest}]) - else + if is_nil(redirect_params["as"]) and identity.actor.type == :account_user do conn |> Phoenix.Controller.put_flash( - :info, + :error, "Please use a client application to access Firezone." ) - |> Phoenix.Controller.redirect(to: ~p"/#{subject.account}") + |> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}") + |> Plug.Conn.halt() + else + conn + |> put_account_session(context.type, provider.account_id, encoded_fragment) + |> signed_in_redirect(identity, context, encoded_fragment, redirect_params) end end - def signed_in_redirect( - conn, - %Auth.Subject{actor: %{type: :account_admin_user}} = subject, - _client_platform, - _client_csrf_token - ) do - redirect_to = Plug.Conn.get_session(conn, :user_return_to) || signed_in_path(subject) + defp signed_in_redirect(conn, identity, %Auth.Context{type: :client}, encoded_fragment, %{ + "as" => "client", + "nonce" => _nonce, + "state" => state + }) do + query = + %{ + fragment: encoded_fragment, + state: state, + actor_name: identity.actor.name, + account_slug: conn.assigns.account.slug, + account_name: conn.assigns.account.name, + identity_provider_identifier: identity.provider_identifier + } + |> Enum.reject(&is_nil(elem(&1, 1))) + |> URI.encode_query() - conn - |> Web.Auth.renew_session() - |> Web.Auth.put_subject_in_session(subject) - |> Plug.Conn.delete_session(:user_return_to) - |> Phoenix.Controller.redirect(to: redirect_to) + Phoenix.Controller.redirect(conn, + external: "firezone-fd0020211111://handle_client_sign_in_callback?#{query}" + ) end - def signed_in_redirect(conn, %Auth.Subject{} = subject, _client_platform, _client_csrf_token) do + defp signed_in_redirect( + conn, + _identity, + %Auth.Context{type: :client}, + _encoded_fragment, + _params + ) do conn - |> Phoenix.Controller.put_flash( - :info, - "Please use a client application to access Firezone." - ) - |> Phoenix.Controller.redirect(to: ~p"/#{subject.account}") + |> Phoenix.Controller.put_flash(:error, "Please use a client application to access Firezone.") + |> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}") + |> Plug.Conn.halt() + end + + defp signed_in_redirect( + conn, + _identity, + %Auth.Context{type: :browser}, + _encoded_fragment, + redirect_params + ) do + account = conn.assigns.account + redirect_to = signed_in_path(account, redirect_params) + Phoenix.Controller.redirect(conn, to: redirect_to) + end + + defp signed_in_path(%Accounts.Account{} = account, %{"redirect_to" => redirect_to}) + when is_binary(redirect_to) do + if String.starts_with?(redirect_to, "/#{account.id}") or + String.starts_with?(redirect_to, "/#{account.slug}") do + redirect_to + else + signed_in_path(account) + end + end + + defp signed_in_path(%Accounts.Account{} = account, _redirect_params) do + signed_in_path(account) + end + + defp signed_in_path(%Accounts.Account{} = account) do + ~p"/#{account}/sites" end @doc """ @@ -121,29 +162,58 @@ defmodule Web.Auth do It clears all session data for safety. See `renew_session/1`. """ - def sign_out(%Plug.Conn{} = conn) do - # token = Plug.Conn.get_session(conn, :session_token) - # subject && Accounts.delete_user_session_token(subject) - - if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do - conn.private.phoenix_endpoint.broadcast(live_socket_id, "disconnect", %{}) - end + def sign_out(%Plug.Conn{} = conn, params) do + account_or_slug = Map.get(conn.assigns, :account) || params["account_id_or_slug"] conn |> renew_session() + |> sign_out_redirect(account_or_slug, params) + end + + defp sign_out_redirect( + %{assigns: %{subject: %Auth.Subject{} = subject}} = conn, + account_or_slug, + params + ) do + post_sign_out_url = post_sign_out_url(account_or_slug, params) + conn.private.phoenix_endpoint.broadcast("sessions:#{subject.token_id}", "disconnect", %{}) + {:ok, _identity, redirect_url} = Auth.sign_out(subject, post_sign_out_url) + Phoenix.Controller.redirect(conn, external: redirect_url) + end + + defp sign_out_redirect(conn, account_or_slug, params) do + post_sign_out_url = post_sign_out_url(account_or_slug, params) + Phoenix.Controller.redirect(conn, external: post_sign_out_url) + end + + defp post_sign_out_url(_account_or_slug, %{"as" => "client", "state" => state}) do + "firezone://handle_client_sign_out_callback?state=#{state}" + end + + defp post_sign_out_url(account_or_slug, _params) do + url(~p"/#{account_or_slug}") end @doc """ - This function renews the session ID and erases the whole - session to avoid fixation attacks. + This function renews the session ID to avoid fixation attacks + and erases the session token from the sessions list. """ def renew_session(%Plug.Conn{} = conn) do preferred_locale = Plug.Conn.get_session(conn, :preferred_locale) + account_id = if Map.get(conn.assigns, :account), do: conn.assigns.account.id + + sessions = + Plug.Conn.get_session(conn, :sessions, []) + |> Enum.reject(fn {_, session_account_id, _} -> + session_account_id == account_id + end) + |> Enum.take(-1 * @remember_last_sessions) conn |> Plug.Conn.configure_session(renew: true) |> Plug.Conn.clear_session() |> Plug.Conn.put_session(:preferred_locale, preferred_locale) + |> Plug.Conn.put_session(:sessions, sessions) end ########################### @@ -151,15 +221,21 @@ defmodule Web.Auth do ########################### def list_recent_account_ids(conn) do - conn = Plug.Conn.fetch_cookies(conn, signed: [@remember_me_cookie_name]) + conn = Plug.Conn.fetch_cookies(conn, signed: [@recent_accounts_cookie_name]) - if recent_account_ids = Map.get(conn.cookies, @remember_me_cookie_name) do + if recent_account_ids = Map.get(conn.cookies, @recent_accounts_cookie_name) do {:ok, :erlang.binary_to_term(recent_account_ids, [:safe]), conn} else {:ok, [], conn} end end + defp prepend_recent_account_ids(conn, account_id) do + update_recent_account_ids(conn, fn recent_account_ids -> + [account_id] ++ recent_account_ids + end) + end + def update_recent_account_ids(conn, callback) when is_function(callback, 1) do {:ok, recent_account_ids, conn} = list_recent_account_ids(conn) @@ -171,9 +247,9 @@ defmodule Web.Auth do Plug.Conn.put_resp_cookie( conn, - @remember_me_cookie_name, + @recent_accounts_cookie_name, recent_account_ids, - @remember_me_cookie_options + @recent_accounts_cookie_options ) end @@ -191,14 +267,53 @@ defmodule Web.Auth do end end + def fetch_account(%Plug.Conn{path_info: [account_id_or_slug | _]} = conn, _opts) do + case Accounts.fetch_account_by_id_or_slug(account_id_or_slug) do + {:ok, account} -> Plug.Conn.assign(conn, :account, account) + _ -> conn + end + end + + def fetch_account(%Plug.Conn{} = conn, _opts) do + conn + end + @doc """ Fetches the session token from the session and assigns the subject to the connection. """ - def fetch_subject_and_account(%Plug.Conn{} = conn, _opts) do + def fetch_subject(%Plug.Conn{} = conn, _opts) do + context = get_auth_context(conn, :browser) + + with account when not is_nil(account) <- Map.get(conn.assigns, :account), + sessions <- Plug.Conn.get_session(conn, :sessions, []), + {:ok, encoded_fragment} <- fetch_token(sessions, account.id, context.type), + {:ok, subject} <- Auth.authenticate(encoded_fragment, context), + true <- subject.account.id == account.id do + conn + |> Plug.Conn.put_session(:live_socket_id, "sessions:#{subject.token_id}") + |> Plug.Conn.assign(:subject, subject) + else + {:error, :unauthorized} -> renew_session(conn) + _ -> conn + end + end + + defp fetch_token(sessions, account_id, context_type) do + Enum.find(sessions, fn {session_context_type, session_account_id, _encoded_fragment} -> + session_context_type == context_type and session_account_id == account_id + end) + |> case do + {_context_type, _account_id, encoded_fragment} -> {:ok, encoded_fragment} + _ -> :error + end + end + + def get_auth_context(%Plug.Conn{} = conn, type) do {location_region, location_city, {location_lat, location_lon}} = get_load_balancer_ip_location(conn) - context = %Auth.Context{ + %Auth.Context{ + type: type, user_agent: Map.get(conn.assigns, :user_agent), remote_ip: conn.remote_ip, remote_ip_location_region: location_region, @@ -206,21 +321,6 @@ defmodule Web.Auth do remote_ip_location_lat: location_lat, remote_ip_location_lon: location_lon } - - with token when not is_nil(token) <- Plug.Conn.get_session(conn, :session_token), - {:ok, subject} <- - Domain.Auth.sign_in(token, context), - {:ok, account} <- - Domain.Accounts.fetch_account_by_id_or_slug( - conn.path_params["account_id_or_slug"], - subject - ) do - conn - |> Plug.Conn.assign(:account, account) - |> Plug.Conn.assign(:subject, subject) - else - _ -> conn - end end defp get_load_balancer_ip_location(%Plug.Conn{} = conn) do @@ -306,15 +406,11 @@ defmodule Web.Auth do Used for routes that require the user to not be authenticated. """ def redirect_if_user_is_authenticated(%Plug.Conn{} = conn, _opts) do - if conn.assigns[:subject] do - client_platform = - Plug.Conn.get_session(conn, :client_platform) || conn.query_params["client_platform"] - - client_csrf_token = - Plug.Conn.get_session(conn, :client_csrf_token) || conn.query_params["client_csrf_token"] + if subject = conn.assigns[:subject] do + redirect_to = signed_in_path(subject.account) conn - |> signed_in_redirect(conn.assigns[:subject], client_platform, client_csrf_token) + |> Phoenix.Controller.redirect(to: redirect_to) |> Plug.Conn.halt() else conn @@ -330,10 +426,13 @@ defmodule Web.Auth do if conn.assigns[:subject] do conn else + redirect_params = maybe_store_return_to(conn) + conn |> Phoenix.Controller.put_flash(:error, "You must log in to access this page.") - |> maybe_store_return_to() - |> Phoenix.Controller.redirect(to: ~p"/#{conn.path_params["account_id_or_slug"]}") + |> Phoenix.Controller.redirect( + to: ~p"/#{conn.path_params["account_id_or_slug"]}?#{redirect_params}" + ) |> Plug.Conn.halt() end end @@ -354,10 +453,12 @@ defmodule Web.Auth do end defp maybe_store_return_to(%{method: "GET"} = conn) do - Plug.Conn.put_session(conn, :user_return_to, Phoenix.Controller.current_path(conn)) + %{"redirect_to" => Phoenix.Controller.current_path(conn)} end - defp maybe_store_return_to(conn), do: conn + defp maybe_store_return_to(_conn) do + %{} + end ########################### ## LiveView @@ -378,7 +479,7 @@ defmodule Web.Auth do Redirects to login page if there's no logged user. * `:redirect_if_user_is_authenticated` - authenticates the user from the session. - Redirects to signed_in_path if there's a logged user. + Redirects to signed in path if there's a logged user. * `:mount_account` - takes `account_id` from path params and loads the given account into the socket assigns using the `subject` mounted via `:mount_subject`. This is useful @@ -404,6 +505,7 @@ defmodule Web.Auth do end """ def on_mount(:mount_subject, params, session, socket) do + socket = mount_account(socket, params, session) {:cont, mount_subject(socket, params, session)} end @@ -412,6 +514,7 @@ defmodule Web.Auth do end def on_mount(:ensure_authenticated, params, session, socket) do + socket = mount_account(socket, params, session) socket = mount_subject(socket, params, session) if socket.assigns[:subject] do @@ -427,6 +530,7 @@ defmodule Web.Auth do end def on_mount(:ensure_account_admin_user_actor, params, session, socket) do + socket = mount_account(socket, params, session) socket = mount_subject(socket, params, session) if socket.assigns[:subject].actor.type == :account_admin_user do @@ -437,15 +541,19 @@ defmodule Web.Auth do end def on_mount(:redirect_if_user_is_authenticated, params, session, socket) do + socket = mount_account(socket, params, session) socket = mount_subject(socket, params, session) if socket.assigns[:subject] do - {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns[:subject]))} + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.account))} else {:cont, socket} end end + # TODO: we need to schedule socket expiration for this subject, so that when it expires + # LiveView socket will be disconnected. Otherwise, you can keep using the system as long as + # socket is active extending the session. defp mount_subject(socket, _params, session) do Phoenix.Component.assign_new(socket, :subject, fn -> user_agent = Phoenix.LiveView.get_connect_info(socket, :user_agent) @@ -455,7 +563,8 @@ defmodule Web.Auth do {location_region, location_city, {location_lat, location_lon}} = get_load_balancer_ip_location(x_headers) - context = %Domain.Auth.Context{ + context = %Auth.Context{ + type: :browser, user_agent: user_agent, remote_ip: real_ip, remote_ip_location_region: location_region, @@ -464,8 +573,12 @@ defmodule Web.Auth do remote_ip_location_lon: location_lon } - with token when not is_nil(token) <- session["session_token"], - {:ok, subject} <- Domain.Auth.sign_in(token, context) do + sessions = session["sessions"] || [] + + with account when not is_nil(account) <- Map.get(socket.assigns, :account), + {:ok, encoded_fragment} <- fetch_token(sessions, account.id, context.type), + {:ok, subject} <- Auth.authenticate(encoded_fragment, context), + true <- subject.account.id == account.id do subject else _ -> nil @@ -473,14 +586,10 @@ defmodule Web.Auth do end) end - defp mount_account( - %{assigns: %{subject: subject}} = socket, - %{"account_id_or_slug" => account_id_or_slug}, - _session - ) do + defp mount_account(socket, %{"account_id_or_slug" => account_id_or_slug}, _session) do Phoenix.Component.assign_new(socket, :account, fn -> with {:ok, account} <- - Domain.Accounts.fetch_account_by_id_or_slug(account_id_or_slug, subject) do + Accounts.fetch_account_by_id_or_slug(account_id_or_slug) do account else _ -> nil diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index d1ef8b6c1..7e043147d 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -939,7 +939,7 @@ defmodule Web.CoreComponents do end def identity_has_email?(identity) do - not is_nil(provider_email(identity)) || identity.provider.adapter == :email + not is_nil(provider_email(identity)) or identity.provider.adapter == :email end defp provider_email(identity) do @@ -992,10 +992,10 @@ defmodule Web.CoreComponents do def last_seen(assigns) do ~H""" - + <%= @schema.last_seen_remote_ip %> - + <%= [ @schema.last_seen_remote_ip_location_region, @schema.last_seen_remote_ip_location_city @@ -1004,12 +1004,15 @@ defmodule Web.CoreComponents do |> Enum.join(", ") %> - <.icon name="hero-arrow-top-right-on-square" class="-ml-1 mb-3 w-3 h-3" /> + <.icon name="hero-arrow-top-right-on-square" class="mb-3 w-3 h-3" /> """ diff --git a/elixir/apps/web/lib/web/components/table_components.ex b/elixir/apps/web/lib/web/components/table_components.ex index 29ed1d45c..6683ce40d 100644 --- a/elixir/apps/web/lib/web/components/table_components.ex +++ b/elixir/apps/web/lib/web/components/table_components.ex @@ -61,31 +61,13 @@ defmodule Web.TableComponents do render = render_slot(action, @mapper.(@row)) not_empty_render?(render) end) %> - - -
-
    -
  • - <%= render_slot(action, @mapper.(@row)) %> -
  • -
-
+ + + <%= render_slot(action, @mapper.(@row)) %> + """ diff --git a/elixir/apps/web/lib/web/controllers/auth_controller.ex b/elixir/apps/web/lib/web/controllers/auth_controller.ex index 97747fd3d..484a631d3 100644 --- a/elixir/apps/web/lib/web/controllers/auth_controller.ex +++ b/elixir/apps/web/lib/web/controllers/auth_controller.ex @@ -4,11 +4,15 @@ defmodule Web.AuthController do alias Domain.Auth.Adapters.OpenIDConnect # This is the cookie which will be used to store the - # state and code verifier for OpenID Connect IdP's + # state during redirect to third-party website, + # eg. state and code verifier for OpenID Connect IdP's @state_cookie_key_prefix "fz_auth_state_" @state_cookie_options [ sign: true, - max_age: 300, + # encrypt: true, + max_age: 30 * 60, + # If `same_site` is set to `Strict` then the cookie will not be sent on + # IdP callback redirects, which will break the auth flow. same_site: "Lax", secure: true, http_only: true @@ -30,23 +34,15 @@ defmodule Web.AuthController do } } = params ) do - redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"]) + redirect_params = Web.Auth.take_sign_in_params(params) + context_type = Web.Auth.fetch_auth_context_type!(redirect_params) + context = Web.Auth.get_auth_context(conn, context_type) + nonce = Web.Auth.fetch_token_nonce!(redirect_params) with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id), - {:ok, subject} <- - Domain.Auth.sign_in( - provider, - provider_identifier, - secret, - conn.assigns.user_agent, - conn.remote_ip - ) do - client_platform = params["client_platform"] - client_csrf_token = params["client_csrf_token"] - - conn - |> persist_recent_account(subject.account) - |> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token) + {:ok, identity, encoded_fragment} <- + Domain.Auth.sign_in(provider, provider_identifier, nonce, secret, context) do + Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params) else {:error, :not_found} -> conn @@ -75,48 +71,48 @@ defmodule Web.AuthController do } } = params ) do - conn = - with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id), - {:ok, identity} <- - Domain.Auth.fetch_identity_by_provider_and_identifier(provider, provider_identifier, - preload: :account - ), - {:ok, identity} <- Domain.Auth.Adapters.Email.request_sign_in_token(identity) do - sign_in_link_params = - take_non_empty_params(params, ["client_platform", "client_csrf_token"]) - - <> = - identity.provider_virtual_state.sign_in_token - - {:ok, _} = - Web.Mailer.AuthEmail.sign_in_link_email( - identity, - email_secret, - conn.assigns.user_agent, - conn.remote_ip, - sign_in_link_params - ) - |> Web.Mailer.deliver() - - put_session(conn, :sign_in_nonce, nonce) - else - _ -> conn - end - - redirect_params = - params - |> take_non_empty_params(["client_platform", "client_csrf_token"]) - |> Map.put("provider_identifier", provider_identifier) + redirect_params = Web.Auth.take_sign_in_params(params) + conn = maybe_send_magic_link_email(conn, provider_id, provider_identifier, redirect_params) + redirect_params = Map.put(redirect_params, "provider_identifier", provider_identifier) conn |> maybe_put_resent_flash(params) - |> put_session(:client_platform, params["client_platform"]) - |> put_session(:client_csrf_token, params["client_csrf_token"]) |> redirect( to: ~p"/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}?#{redirect_params}" ) end + defp maybe_send_magic_link_email(conn, provider_id, provider_identifier, redirect_params) do + with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id), + {:ok, identity} <- + Domain.Auth.fetch_active_identity_by_provider_and_identifier( + provider, + provider_identifier, + preload: :account + ), + {:ok, identity} <- Domain.Auth.Adapters.Email.request_sign_in_token(identity) do + # We split the secret into two components, the first 5 bytes is the code we send to the user + # the rest is the secret we store in the cookie. This is to prevent authorization code injection + # attacks where you can trick user into logging in into a attacker account. + <> = + identity.provider_virtual_state.sign_in_token + + {:ok, _} = + Web.Mailer.AuthEmail.sign_in_link_email( + identity, + email_secret, + conn.assigns.user_agent, + conn.remote_ip, + redirect_params + ) + |> Web.Mailer.deliver() + + put_auth_state(conn, provider.id, {nonce, redirect_params}) + else + _ -> conn + end + end + defp maybe_put_resent_flash(conn, %{"resend" => "true"}), do: put_flash(conn, :info, "Email was resent.") @@ -136,43 +132,40 @@ defmodule Web.AuthController do "secret" => email_secret } = params ) do - client_platform = get_session(conn, :client_platform) || params["client_platform"] - client_csrf_token = get_session(conn, :client_csrf_token) || params["client_csrf_token"] + with {:ok, {nonce, redirect_params}, conn} <- fetch_auth_state(conn, provider_id) do + conn = delete_auth_state(conn, provider_id) + secret = String.downcase(email_secret) <> nonce + context_type = Web.Auth.fetch_auth_context_type!(redirect_params) + context = Web.Auth.get_auth_context(conn, context_type) + nonce = Web.Auth.fetch_token_nonce!(redirect_params) - redirect_params = - put_if_not_empty(:client_platform, client_platform) - |> put_if_not_empty(:client_csrf_token, client_csrf_token) + with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id), + {:ok, identity, encoded_fragment} <- + Domain.Auth.sign_in(provider, identity_id, nonce, secret, context) do + Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params) + else + {:error, :not_found} -> + conn + |> put_flash(:error, "You may not use this method to sign in.") + |> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}") - with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id), - nonce = get_session(conn, :sign_in_nonce) || "=", - {:ok, subject} <- - Domain.Auth.sign_in( - provider, - identity_id, - String.downcase(email_secret) <> nonce, - conn.assigns.user_agent, - conn.remote_ip - ) do - conn - |> delete_session(:client_platform) - |> delete_session(:client_csrf_token) - |> delete_session(:sign_in_nonce) - |> persist_recent_account(subject.account) - |> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token) + {:error, _reason} -> + redirect_params = Map.put(redirect_params, "provider_identifier", identity_id) + + conn + |> put_flash(:error, "The sign in token is invalid or expired.") + |> redirect( + to: + ~p"/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}?#{redirect_params}" + ) + end else - {:error, :not_found} -> - conn - |> put_flash(:error, "You may not use this method to sign in.") - |> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}") - - {:error, _reason} -> - redirect_params = put_if_not_empty(redirect_params, "provider_identifier", identity_id) + :error -> + params = Web.Auth.take_sign_in_params(params) conn - |> put_flash(:error, "The sign in token is invalid or expired.") - |> redirect( - to: ~p"/#{account_id_or_slug}/sign_in/providers/email/#{provider_id}?#{redirect_params}" - ) + |> put_flash(:error, "The sign in token is expired.") + |> redirect(to: ~p"/#{account_id_or_slug}?#{params}") end end @@ -188,20 +181,16 @@ defmodule Web.AuthController do } = params ) do with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id) do - conn = put_session(conn, :client_platform, params["client_platform"]) - conn = put_session(conn, :client_csrf_token, params["client_csrf_token"]) - redirect_url = url(~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/handle_callback") - redirect_to_idp(conn, redirect_url, provider) + redirect_params = Web.Auth.take_sign_in_params(params) + redirect_to_idp(conn, redirect_url, provider, %{}, redirect_params) else {:error, :not_found} -> - redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"]) - conn |> put_flash(:error, "You may not use this method to sign in.") - |> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}") + |> redirect(to: ~p"/#{account_id_or_slug}") end end @@ -209,16 +198,14 @@ defmodule Web.AuthController do %Plug.Conn{} = conn, redirect_url, %Domain.Auth.Provider{} = provider, - params \\ %{} + params \\ %{}, + redirect_params \\ %{} ) do {:ok, authorization_url, {state, code_verifier}} = OpenIDConnect.authorization_uri(provider, redirect_url, params) - key = state_cookie_key(provider.id) - value = :erlang.term_to_binary({state, code_verifier}) - conn - |> put_resp_cookie(key, value, @state_cookie_options) + |> put_auth_state(provider.id, {redirect_params, state, code_verifier}) |> redirect(external: authorization_url) end @@ -231,29 +218,22 @@ defmodule Web.AuthController do "state" => state, "code" => code }) do - client_platform = get_session(conn, :client_platform) - client_csrf_token = get_session(conn, :client_csrf_token) - - redirect_params = - put_if_not_empty(:client_platform, client_platform) - |> put_if_not_empty(:client_csrf_token, client_csrf_token) - - with {:ok, code_verifier, conn} <- verify_state_and_fetch_verifier(conn, provider_id, state) do + with {:ok, redirect_params, code_verifier, conn} <- + verify_idp_state_and_fetch_verifier(conn, provider_id, state) do payload = { url(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback"), code_verifier, code } + context_type = Web.Auth.fetch_auth_context_type!(redirect_params) + context = Web.Auth.get_auth_context(conn, context_type) + nonce = Web.Auth.fetch_token_nonce!(redirect_params) + with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id), - {:ok, subject} <- - Domain.Auth.sign_in(provider, payload, conn.assigns.user_agent, conn.remote_ip) do - conn - |> delete_session(:client_platform) - |> delete_session(:client_csrf_token) - |> delete_session(:sign_in_nonce) - |> persist_recent_account(subject.account) - |> Web.Auth.signed_in_redirect(subject, client_platform, client_csrf_token) + {:ok, identity, encoded_fragment} <- + Domain.Auth.sign_in(provider, nonce, payload, context) do + Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params) else {:error, :not_found} -> conn @@ -269,60 +249,46 @@ defmodule Web.AuthController do {:error, :invalid_state, conn} -> conn |> put_flash(:error, "Your session has expired, please try again.") - |> redirect(to: ~p"/#{account_id}?#{redirect_params}") + |> redirect(to: ~p"/#{account_id}") end end - def verify_state_and_fetch_verifier(conn, provider_id, state) do + def verify_idp_state_and_fetch_verifier(conn, provider_id, state) do + with {:ok, {redirect_params, persisted_state, persisted_verifier}, conn} <- + fetch_auth_state(conn, provider_id), + :ok <- OpenIDConnect.ensure_states_equal(state, persisted_state) do + {:ok, redirect_params, persisted_verifier, delete_auth_state(conn, provider_id)} + else + _ -> {:error, :invalid_state, delete_auth_state(conn, provider_id)} + end + end + + def sign_out(conn, params) do + Auth.sign_out(conn, params) + end + + @doc false + def put_auth_state(conn, provider_id, state) do + key = state_cookie_key(provider_id) + value = :erlang.term_to_binary(state) + put_resp_cookie(conn, key, value, @state_cookie_options) + end + + defp fetch_auth_state(conn, provider_id) do key = state_cookie_key(provider_id) conn = fetch_cookies(conn, signed: [key]) - with {:ok, encoded_state} <- Map.fetch(conn.cookies, key), - {persisted_state, persisted_verifier} <- :erlang.binary_to_term(encoded_state, [:safe]), - :ok <- OpenIDConnect.ensure_states_equal(state, persisted_state) do - {:ok, persisted_verifier, delete_resp_cookie(conn, key, @state_cookie_options)} - else - _ -> {:error, :invalid_state, delete_resp_cookie(conn, key, @state_cookie_options)} + with {:ok, encoded_state} <- Map.fetch(conn.cookies, key) do + {:ok, :erlang.binary_to_term(encoded_state, [:safe]), conn} end end + defp delete_auth_state(conn, provider_id) do + key = state_cookie_key(provider_id) + delete_resp_cookie(conn, key, @state_cookie_options) + end + defp state_cookie_key(provider_id) do @state_cookie_key_prefix <> provider_id end - - def sign_out(%{assigns: %{subject: subject}} = conn, %{ - "account_id_or_slug" => account_id_or_slug - }) do - redirect_params = Map.take(conn.params, ["client_platform"]) - - {:ok, _identity, redirect_url} = - Domain.Auth.sign_out(subject.identity, url(~p"/#{account_id_or_slug}?#{redirect_params}")) - - conn - |> Auth.sign_out() - |> redirect(external: redirect_url) - end - - def sign_out(conn, %{"account_id_or_slug" => account_id_or_slug}) do - redirect_params = Map.take(conn.params, ["client_platform"]) - - conn - |> Auth.sign_out() - |> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}") - end - - defp persist_recent_account(conn, %Domain.Accounts.Account{} = account) do - Auth.update_recent_account_ids(conn, fn recent_account_ids -> - [account.id] ++ recent_account_ids - end) - end - - defp take_non_empty_params(map, keys) do - map |> Map.take(keys) |> Map.reject(fn {_key, value} -> value in ["", nil] end) - end - - defp put_if_not_empty(map \\ %{}, key, value) - defp put_if_not_empty(map, _key, ""), do: map - defp put_if_not_empty(map, _key, nil), do: map - defp put_if_not_empty(map, key, value), do: Map.put(map, key, value) end diff --git a/elixir/apps/web/lib/web/controllers/home_controller.ex b/elixir/apps/web/lib/web/controllers/home_controller.ex index 9142445ce..fbadbb9a1 100644 --- a/elixir/apps/web/lib/web/controllers/home_controller.ex +++ b/elixir/apps/web/lib/web/controllers/home_controller.ex @@ -3,6 +3,8 @@ defmodule Web.HomeController do alias Domain.Accounts def home(conn, params) do + signed_in_account_ids = conn |> get_session("sessions", []) |> Enum.map(&elem(&1, 0)) + {accounts, conn} = with {:ok, recent_account_ids, conn} <- Web.Auth.list_recent_account_ids(conn), {:ok, accounts} <- Accounts.list_accounts_by_ids(recent_account_ids) do @@ -16,21 +18,20 @@ defmodule Web.HomeController do _other -> {[], conn} end - redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"]) + params = Web.Auth.take_sign_in_params(params) conn |> put_layout(html: {Web.Layouts, :public}) - |> render("home.html", accounts: accounts, redirect_params: redirect_params) + |> render("home.html", + accounts: accounts, + signed_in_account_ids: signed_in_account_ids, + params: params + ) end def redirect_to_sign_in(conn, %{"account_id_or_slug" => account_id_or_slug} = params) do account_id_or_slug = String.downcase(account_id_or_slug) - redirect_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"]) - - redirect(conn, to: ~p"/#{account_id_or_slug}?#{redirect_params}") - end - - defp take_non_empty_params(map, keys) do - map |> Map.take(keys) |> Map.reject(fn {_key, value} -> value in ["", nil] end) + params = Web.Auth.take_sign_in_params(params) + redirect(conn, to: ~p"/#{account_id_or_slug}?#{params}") end end diff --git a/elixir/apps/web/lib/web/controllers/home_html.ex b/elixir/apps/web/lib/web/controllers/home_html.ex index 1af0506a4..04bfb08ec 100644 --- a/elixir/apps/web/lib/web/controllers/home_html.ex +++ b/elixir/apps/web/lib/web/controllers/home_html.ex @@ -24,18 +24,14 @@ defmodule Web.HomeHTML do <.account_button :for={account <- @accounts} account={account} - redirect_params={@redirect_params} + signed_in?={account.id in @signed_in_account_ids} + params={@params} /> <.separator :if={@accounts != []} /> - <.form - :let={f} - for={%{}} - action={~p"/?#{@redirect_params}"} - class="space-y-4 lg:space-y-6" - > + <.form :let={f} for={%{}} action={~p"/?#{@params}"} class="space-y-4 lg:space-y-6"> <.input field={f[:account_id_or_slug]} type="text" @@ -51,7 +47,10 @@ defmodule Web.HomeHTML do

Don't have an account? @@ -68,7 +67,7 @@ defmodule Web.HomeHTML do def account_button(assigns) do ~H""" - <%= @account.name %> + + + <.icon name="hero-shield-check" class="w-4 h-4" /> + """ end diff --git a/elixir/apps/web/lib/web/endpoint.ex b/elixir/apps/web/lib/web/endpoint.ex index 34244a444..0928279b3 100644 --- a/elixir/apps/web/lib/web/endpoint.ex +++ b/elixir/apps/web/lib/web/endpoint.ex @@ -1,5 +1,6 @@ defmodule Web.Endpoint do use Phoenix.Endpoint, otp_app: :web + import Web.Auth if Application.compile_env(:domain, :sql_sandbox) do plug Phoenix.Ecto.SQL.Sandbox @@ -55,6 +56,8 @@ defmodule Web.Endpoint do pass: ["*/*"], json_decoder: Phoenix.json_library() + plug :fetch_user_agent + plug Web.Session plug Web.Router diff --git a/elixir/apps/web/lib/web/live/actors/edit.ex b/elixir/apps/web/lib/web/live/actors/edit.ex index 8b17419e7..322ee97cf 100644 --- a/elixir/apps/web/lib/web/live/actors/edit.ex +++ b/elixir/apps/web/lib/web/live/actors/edit.ex @@ -12,13 +12,15 @@ defmodule Web.Actors.Edit do groups = Enum.reject(groups, &Actors.group_synced?/1) - {:ok, socket, - temporary_assigns: [ - actor: actor, - groups: groups, - form: to_form(changeset), - page_title: "Edit actor #{actor.name}" - ]} + socket = + assign(socket, + actor: actor, + groups: groups, + form: to_form(changeset), + page_title: "Edit actor #{actor.name}" + ) + + {:ok, socket} else _other -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/actors/index.ex b/elixir/apps/web/lib/web/live/actors/index.ex index 4cc2b03ef..91264188c 100644 --- a/elixir/apps/web/lib/web/live/actors/index.ex +++ b/elixir/apps/web/lib/web/live/actors/index.ex @@ -8,15 +8,16 @@ defmodule Web.Actors.Index do with {:ok, actors} <- Actors.list_actors(socket.assigns.subject, preload: [identities: :provider]), {:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject), - {:ok, providers} <- - Auth.list_providers_for_account(socket.assigns.account, socket.assigns.subject) do - {:ok, socket, - temporary_assigns: [ - actors: actors, - actor_groups: actor_groups, - providers_by_id: Map.new(providers, &{&1.id, &1}), - page_title: "Actors" - ]} + {:ok, providers} <- Auth.list_providers(socket.assigns.subject) do + socket = + assign(socket, + actors: actors, + actor_groups: actor_groups, + providers_by_id: Map.new(providers, &{&1.id, &1}), + page_title: "Actors" + ) + + {:ok, socket} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex b/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex index 41d4339ff..cdc78ff68 100644 --- a/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex +++ b/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex @@ -17,7 +17,7 @@ defmodule Web.Actors.ServiceAccounts.New do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/actors/service_accounts/new_identity.ex b/elixir/apps/web/lib/web/live/actors/service_accounts/new_identity.ex index 70c6b3844..6f6337b45 100644 --- a/elixir/apps/web/lib/web/live/actors/service_accounts/new_identity.ex +++ b/elixir/apps/web/lib/web/live/actors/service_accounts/new_identity.ex @@ -20,7 +20,7 @@ defmodule Web.Actors.ServiceAccounts.NewIdentity do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else _other -> raise Web.LiveErrors.NotFoundError end @@ -103,7 +103,13 @@ defmodule Web.Actors.ServiceAccounts.NewIdentity do attrs, socket.assigns.subject ) do - {:ok, encoded_token} = Auth.create_access_token_for_identity(identity) + {:ok, encoded_token} = + Auth.create_service_account_token( + socket.assigns.provider, + identity, + socket.assigns.subject + ) + {:noreply, assign(socket, encoded_token: encoded_token)} else {:error, changeset} -> diff --git a/elixir/apps/web/lib/web/live/actors/show.ex b/elixir/apps/web/lib/web/live/actors/show.ex index abcc96e76..b9d46fcb3 100644 --- a/elixir/apps/web/lib/web/live/actors/show.ex +++ b/elixir/apps/web/lib/web/live/actors/show.ex @@ -1,10 +1,10 @@ defmodule Web.Actors.Show do use Web, :live_view import Web.Actors.Components - alias Domain.{Auth, Flows, Clients} + alias Domain.{Auth, Tokens, Flows, Clients} alias Domain.Actors - def mount(%{"id" => id}, _session, socket) do + def mount(%{"id" => id}, _token, socket) do with {:ok, actor} <- Actors.fetch_actor_by_id(id, socket.assigns.subject, preload: [ @@ -13,6 +13,10 @@ defmodule Web.Actors.Show do clients: [] ] ), + {:ok, tokens} <- + Tokens.list_tokens_for(actor, socket.assigns.subject, + preload: [identity: [:provider, created_by_identity: [:actor]]] + ), {:ok, flows} <- Flows.list_flows_for(actor, socket.assigns.subject, preload: [gateway: [:group], client: [], policy: [:resource, :actor_group]] @@ -24,6 +28,7 @@ defmodule Web.Actors.Show do assign(socket, actor: actor, flows: flows, + tokens: tokens, page_title: actor.name, flow_activities_enabled?: Domain.Config.flow_activities_enabled?() )} @@ -93,6 +98,7 @@ defmodule Web.Actors.Show do <.section> + <:title>Authentication Identities <:action :if={is_nil(@actor.deleted_at)}> <.add_button @@ -116,7 +122,6 @@ defmodule Web.Actors.Show do <:col :let={identity} label="IDENTITY" sortable="false"> <.identity_identifier account={@account} identity={identity} /> - <:col :let={identity} label="CREATED" sortable="false"> <.created_by account={@account} schema={identity} /> @@ -124,29 +129,27 @@ defmodule Web.Actors.Show do <.relative_datetime datetime={identity.last_seen_at} /> <:action :let={identity}> - + <:action :let={identity}> - + <:empty>

@@ -175,6 +178,59 @@ defmodule Web.Actors.Show do + <.section> + <:title>Authentication Tokens + + <:action :if={is_nil(@actor.deleted_at)}> + <.delete_button + phx-click="revoke_all_tokens" + data-confirm="Are you sure you want to revoke all tokens? This will immediately sign the actor out of all clients." + > + Revoke All + + + + <:content> + <.table id="tokens" rows={@tokens} row_id={&"tokens-#{&1.id}"}> + <:col :let={token} label="TYPE" sortable="false"> + <%= token.type %> + + <:col :let={token} label="IDENTITY" sortable="false"> + <.identity_identifier account={@account} identity={token.identity} /> + + <:col :let={token} label="CREATED"> + <.created_by account={@account} schema={token} /> + + <:col :let={token} label="LAST USED (IP)"> +

+ <.relative_datetime datetime={token.last_seen_at} /> +

+

+ <.last_seen schema={token} /> +

+ + <:col :let={token} label="EXPIRES"> + <.relative_datetime datetime={token.expires_at} /> + + <:action :let={token}> + <.delete_button + phx-click="revoke_token" + data-confirm="Are you sure you want to revoke this token?" + phx-value-id={token.id} + class={[ + "block w-full py-2 px-4 hover:bg-gray-100" + ]} + > + Revoke + + + <:empty> +
No authentication tokens to display.
+ + + + + <.section> <:title>Clients @@ -376,6 +432,39 @@ defmodule Web.Actors.Show do {:noreply, socket} end + def handle_event("revoke_all_tokens", _params, socket) do + {:ok, deleted_count} = Tokens.delete_tokens_for(socket.assigns.actor, socket.assigns.subject) + + {:ok, tokens} = + Tokens.list_tokens_for(socket.assigns.actor, socket.assigns.subject, + preload: [identity: [:provider, created_by_identity: [:actor]]] + ) + + socket = + socket + |> put_flash(:info, "#{deleted_count} token(s) were revoked.") + |> assign(tokens: tokens) + + {:noreply, socket} + end + + def handle_event("revoke_token", %{"id" => id}, socket) do + {:ok, token} = Tokens.fetch_token_by_id(id, socket.assigns.subject) + {:ok, _token} = Tokens.delete_token(token, socket.assigns.subject) + + {:ok, tokens} = + Tokens.list_tokens_for(socket.assigns.actor, socket.assigns.subject, + preload: [identity: [:provider, created_by_identity: [:actor]]] + ) + + socket = + socket + |> put_flash(:info, "Token was revoked.") + |> assign(tokens: tokens) + + {:noreply, socket} + end + defp last_seen_at(identities) do identities |> Enum.reject(&is_nil(&1.last_seen_at)) diff --git a/elixir/apps/web/lib/web/live/actors/users/new.ex b/elixir/apps/web/lib/web/live/actors/users/new.ex index aca3a9e6f..f7a9be6f7 100644 --- a/elixir/apps/web/lib/web/live/actors/users/new.ex +++ b/elixir/apps/web/lib/web/live/actors/users/new.ex @@ -13,7 +13,7 @@ defmodule Web.Actors.Users.New do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/actors/users/new_identity.ex b/elixir/apps/web/lib/web/live/actors/users/new_identity.ex index bd14b21a8..a58350b53 100644 --- a/elixir/apps/web/lib/web/live/actors/users/new_identity.ex +++ b/elixir/apps/web/lib/web/live/actors/users/new_identity.ex @@ -30,7 +30,7 @@ defmodule Web.Actors.Users.NewIdentity do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else _other -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/clients/edit.ex b/elixir/apps/web/lib/web/live/clients/edit.ex index 1e4a9b52b..336b81d9c 100644 --- a/elixir/apps/web/lib/web/live/clients/edit.ex +++ b/elixir/apps/web/lib/web/live/clients/edit.ex @@ -6,7 +6,8 @@ defmodule Web.Clients.Edit do with {:ok, client} <- Clients.fetch_client_by_id(id, socket.assigns.subject), nil <- client.deleted_at do changeset = Clients.change_client(client) - {:ok, assign(socket, client: client, form: to_form(changeset))} + socket = assign(socket, client: client, form: to_form(changeset)) + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else _other -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/flows/show.ex b/elixir/apps/web/lib/web/live/flows/show.ex index 87f7fe78e..d5e0a45aa 100644 --- a/elixir/apps/web/lib/web/live/flows/show.ex +++ b/elixir/apps/web/lib/web/live/flows/show.ex @@ -13,7 +13,7 @@ defmodule Web.Flows.Show do resource: [] ] ) do - {:ok, socket, temporary_assigns: [flow: flow]} + {:ok, assign(socket, flow: flow)} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/groups/edit.ex b/elixir/apps/web/lib/web/live/groups/edit.ex index ff8186849..d0d3f4c32 100644 --- a/elixir/apps/web/lib/web/live/groups/edit.ex +++ b/elixir/apps/web/lib/web/live/groups/edit.ex @@ -8,7 +8,8 @@ defmodule Web.Groups.Edit do nil <- group.deleted_at, false <- Actors.group_synced?(group) do changeset = Actors.change_group(group) - {:ok, assign(socket, group: group, form: to_form(changeset))} + socket = assign(socket, group: group, form: to_form(changeset)) + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else _other -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/groups/index.ex b/elixir/apps/web/lib/web/live/groups/index.ex index d541a879b..cc71c94bc 100644 --- a/elixir/apps/web/lib/web/live/groups/index.ex +++ b/elixir/apps/web/lib/web/live/groups/index.ex @@ -9,12 +9,14 @@ defmodule Web.Groups.Index do preload: [:provider, created_by_identity: [:actor]] ), {:ok, group_actors} <- Actors.peek_group_actors(groups, 3, socket.assigns.subject) do - {:ok, socket, - temporary_assigns: [ - page_title: "Groups", - groups: groups, - group_actors: group_actors - ]} + socket = + assign(socket, + page_title: "Groups", + groups: groups, + group_actors: group_actors + ) + + {:ok, socket} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/groups/new.ex b/elixir/apps/web/lib/web/live/groups/new.ex index 75bafff32..c5ab6e630 100644 --- a/elixir/apps/web/lib/web/live/groups/new.ex +++ b/elixir/apps/web/lib/web/live/groups/new.ex @@ -4,7 +4,9 @@ defmodule Web.Groups.New do def mount(_params, _session, socket) do changeset = Actors.new_group() - {:ok, assign(socket, form: to_form(changeset))} + + {:ok, assign(socket, form: to_form(changeset)), + temporary_assigns: [form: %Phoenix.HTML.Form{}]} end def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/relay_groups/edit.ex b/elixir/apps/web/lib/web/live/relay_groups/edit.ex index ecccd57da..dc4185bcf 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/edit.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/edit.ex @@ -7,7 +7,8 @@ defmodule Web.RelayGroups.Edit do {:ok, group} <- Relays.fetch_group_by_id(id, socket.assigns.subject), nil <- group.deleted_at do changeset = Relays.change_group(group) - {:ok, assign(socket, group: group, form: to_form(changeset))} + socket = assign(socket, group: group, form: to_form(changeset)) + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else _other -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/relay_groups/new.ex b/elixir/apps/web/lib/web/live/relay_groups/new.ex index dc15b5c62..8c8886275 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/new.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/new.ex @@ -5,7 +5,9 @@ defmodule Web.RelayGroups.New do def mount(_params, _session, socket) do with true <- Domain.Config.self_hosted_relays_enabled?() do changeset = Relays.new_group() - {:ok, assign(socket, form: to_form(changeset))} + + {:ok, assign(socket, form: to_form(changeset)), + temporary_assigns: [form: %Phoenix.HTML.Form{}]} else _other -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex index ec7358089..d21125825 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex @@ -138,9 +138,6 @@ defmodule Web.Settings.IdentityProviders.Components do def view_provider(account, %{adapter: :google_workspace} = provider), do: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}" - def view_provider(account, %{adapter: :saml} = provider), - do: ~p"/#{account}/settings/identity_providers/saml/#{provider}" - def sync_status(%{provider: %{provisioner: :custom}} = assigns) do ~H"""
diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex index 4297d0977..051c8065e 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex @@ -9,7 +9,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do def redirect_to_idp(conn, %{"provider_id" => provider_id}) do account = conn.assigns.account - with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id) do + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject) do redirect_url = url( ~p"/#{provider.account_id}/settings/identity_providers/google_workspace/#{provider}/handle_callback" @@ -32,8 +32,8 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do account = conn.assigns.account subject = conn.assigns.subject - with {:ok, code_verifier, conn} <- - Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do + with {:ok, _redirect_params, code_verifier, conn} <- + Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do payload = { url( ~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider_id}/handle_callback" @@ -42,7 +42,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do code } - with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id), + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject), {:ok, identity} <- GoogleWorkspace.verify_and_upsert_identity(subject.actor, provider, payload), attrs = %{ diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex index b3e0e0f8d..bb5fd8533 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex @@ -13,7 +13,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Edit do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex index 2208f36e3..48043fb3f 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex @@ -19,7 +19,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.New do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} end def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex index 38b79e00a..283459a7c 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex @@ -4,21 +4,22 @@ defmodule Web.Settings.IdentityProviders.Index do alias Domain.{Auth, Actors} def mount(_params, _session, socket) do - account = socket.assigns.account subject = socket.assigns.subject - with {:ok, providers} <- Auth.list_providers_for_account(account, subject), + with {:ok, providers} <- Auth.list_providers(subject), {:ok, identities_count_by_provider_id} <- Auth.fetch_identities_count_grouped_by_provider_id(subject), {:ok, groups_count_by_provider_id} <- Actors.fetch_groups_count_grouped_by_provider_id(subject) do - {:ok, socket, - temporary_assigns: [ - identities_count_by_provider_id: identities_count_by_provider_id, - groups_count_by_provider_id: groups_count_by_provider_id, - providers: providers, - page_title: "Identity Providers Settings" - ]} + socket = + assign(socket, + identities_count_by_provider_id: identities_count_by_provider_id, + groups_count_by_provider_id: groups_count_by_provider_id, + providers: providers, + page_title: "Identity Providers Settings" + ) + + {:ok, socket} else _ -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex index 3db56c561..7edd28e9a 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex @@ -4,15 +4,8 @@ defmodule Web.Settings.IdentityProviders.New do def mount(_params, _session, socket) do {:ok, adapters} = Auth.list_provider_adapters() - - socket = - socket - |> assign(:form, %{}) - - {:ok, socket, - temporary_assigns: [ - adapters: adapters - ]} + socket = assign(socket, form: %{}, adapters: adapters) + {:ok, socket} end def handle_event("submit", %{"next" => next}, socket) do diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex index 20a44e391..7a667ff04 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex @@ -9,7 +9,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do def redirect_to_idp(conn, %{"provider_id" => provider_id}) do account = conn.assigns.account - with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id) do + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject) do redirect_url = url( ~p"/#{provider.account_id}/settings/identity_providers/openid_connect/#{provider.id}/handle_callback" @@ -32,8 +32,8 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do account = conn.assigns.account subject = conn.assigns.subject - with {:ok, code_verifier, conn} <- - Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do + with {:ok, _redirect_params, code_verifier, conn} <- + Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do payload = { url( ~p"/#{account.id}/settings/identity_providers/openid_connect/#{provider_id}/handle_callback" @@ -42,7 +42,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do code } - with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id), + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, conn.assigns.subject), {:ok, _identity} <- OpenIDConnect.verify_and_upsert_identity(subject.actor, provider, payload), attrs = %{adapter_state: %{status: :connected}, disabled_at: nil}, @@ -95,8 +95,8 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do }) do account = conn.assigns.account - with {:ok, _code_verifier, conn} <- - Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do + with {:ok, _redirect_params, _code_verifier, conn} <- + Web.AuthController.verify_idp_state_and_fetch_verifier(conn, provider_id, state) do conn |> put_flash(:error, "Your IdP returned an error (" <> error <> "): " <> error_description) |> redirect(to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}") diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex index be3571a4d..4bc5e55b1 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex @@ -13,7 +13,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Edit do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex index 80c42ca5f..41d65968c 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex @@ -19,7 +19,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.New do form: to_form(changeset) ) - {:ok, socket} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} end def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex deleted file mode 100644 index 234057d58..000000000 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex +++ /dev/null @@ -1,220 +0,0 @@ -defmodule Web.Settings.IdentityProviders.SAML.Components do - @moduledoc """ - Provides components that can be shared across forms. - """ - use Phoenix.Component - use Web, :verified_routes - import Web.CoreComponents - import Web.FormComponents - alias Phoenix.LiveView.JS - - @doc """ - Conditionally renders form fields corresponding to a given provisioning strategy type. - - ## Examples - - <.provisioning_strategy_form form={%{ - provisioning_strategy: "jit", - jit_user_filter_type: "email_allowlist", - jit_user_filter_value: "jamil@foo.dev,andrew@foo.dev", - jit_extract_groups: true - }} /> - - <.provisioning_strategy_form form={@form} /> - """ - attr :form, :map, required: true, doc: "The form to which this component belongs." - - def provisioning_strategy_form(assigns) do - ~H""" -

Provisioning strategy

-
    -
  • -
    - <.input - id="provisioning_strategy_jit" - label="Just-in-time" - type="radio" - value="jit" - field={@form[:provisioning_strategy]} - checked={@form[:provisioning_strategy].value == "jit"} - required - /> -
    -

    - Provision users and groups on the fly when they first sign in. -

    -
  • -
  • -
    - <.input - id="provisioning_strategy_scim" - label="SCIM 2.0" - type="radio" - value="scim" - field={@form[:provisioning_strategy]} - checked={@form[:provisioning_strategy].value == "scim"} - required - /> -
    -

    - Provision users using the SCIM 2.0 protocol. Requires a supported identity provider. -

    -
  • -
  • -
    - <.input - id="provisioning_strategy_manual" - label="Manual" - type="radio" - value="manual" - field={@form[:provisioning_strategy]} - checked={@form[:provisioning_strategy].value == "manual"} - required - /> -
    -

    - Disable automatic provisioning and manually manage users and groups. -

    -
  • -
- <%= if @form[:provisioning_strategy].value == "jit" do %> -
-
- <.input - label="User filter" - type="select" - field={@form[:jit_user_filter_type]} - options={[ - [value: "email_allowlist", key: "Email allowlist"], - [value: "same_domain", key: "Allow from same email domain"], - [value: "allow_all", key: "Allow any authenticated user"] - ]} - > - -
-
- <%= if @form[:jit_user_filter_type].value == "email_allowlist" do %> - <.input - label="Email allowlist" - autocomplete="off" - field={@form[:jit_user_filter_value]} - placeholder="Comma-delimited list of email addresses" - required - /> - <% end %> -
-
-
-
- <.input - label="Extract group membership information" - type="checkbox" - field={@form[:jit_extract_groups]} - /> -

- <.link - class="text-accent-500 hover:underline" - href="https://www.firezone.dev/kb/authenticate/user-group-sync?utm_source=product" - target="_blank" - > - Read more about group extraction. - <.icon name="hero-arrow-top-right-on-square" class="-ml-1 mb-3 w-3 h-3" /> - -

-
-
- <% end %> - """ - end - - def provisioning_status(assigns) do - ~H""" - - <.header> - <:title>Provisioning - -
- - - - - - - - - - - - - - - -
- Type - - SCIM 2.0 -
- Endpoint - -
- - - <%= "/#{@account}/scim/v2" %> - -
-
- Token - -
- - - - - ••••••••••••••••••••••••••••••••••••••••••••• - - -
-
-
- """ - end - - def toggle_scim_token(js \\ %JS{}) do - js - |> JS.toggle(to: "#visible-token") - |> JS.toggle(to: "#hidden-token") - end -end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex deleted file mode 100644 index ac90c1fc4..000000000 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule Web.Settings.IdentityProviders.SAML.New do - use Web, :live_view - import Web.Settings.IdentityProviders.SAML.Components - - # TODO: Use a changeset for this - @form_initializer %{ - "type" => "saml", - "scopes" => "openid profile email offline_access", - "provisioning_strategy" => "scim", - "saml_sign_requests" => true, - "saml_sign_metadata" => true, - "saml_require_signed_assertions" => true, - "saml_require_signed_envelopes" => true - } - - def mount(_params, _session, socket) do - {:ok, assign(socket, form: to_form(@form_initializer))} - end - - def render(assigns) do - ~H""" - <.breadcrumbs account={@account}> - <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> - Identity Providers Settings - - <.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}> - Create Identity Provider - - <.breadcrumb path={~p"/#{@account}/settings/identity_providers/saml/new"}>SAML - - <.section> - <:title> - Add a new SAML Identity Provider - - <:content> -
- <.form for={@form} id="saml-form" phx-change="change" phx-submit="submit"> -

SAML configuration

-
-
- <.input - label="Name" - autocomplete="off" - field={@form[:name]} - class={[ - "bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded", - "block w-full p-2.5" - ]} - placeholder="Name this identity provider" - required - /> -

- A friendly name for this identity provider. This will be displayed to end-users. -

-
-
- <.input - label="Metadata" - type="textarea" - field={@form[:metadata]} - placeholder="SAML XML Metadata from your identity provider" - required - /> -
-
- <.input label="Sign requests" type="checkbox" field={@form[:saml_sign_requests]} /> -
-
- <.input label="Sign metadata" type="checkbox" field={@form[:saml_sign_metadata]} /> -
-
- <.input - label="Require signed assertions" - type="checkbox" - field={@form[:saml_require_signed_assertions]} - /> -
-
- <.input - label="Require signed envelopes" - type="checkbox" - field={@form[:saml_require_signed_envelopes]} - /> -
-
- - <.provisioning_strategy_form form={@form} /> - - <.submit_button> - Create Identity Provider - - -
- - - """ - end - - def handle_event("change", params, socket) do - # TODO: Validations - # changeset = ProvisioningStrategies.changeset(%ProvisioningStrategy{}, params) - - {:noreply, assign(socket, form: to_form(params))} - end - - def handle_event("submit", _params, socket) do - # TODO: Create identity provider - idp = %{id: "DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - - {:noreply, - push_navigate(socket, - to: ~p"/#{socket.assigns.subject.account}/settings/identity_providers/saml/#{idp.id}" - )} - end -end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex deleted file mode 100644 index 2dbee13a6..000000000 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex +++ /dev/null @@ -1,168 +0,0 @@ -defmodule Web.Settings.IdentityProviders.SAML.Show do - use Web, :live_view - alias Domain.Auth - - def mount(%{"provider_id" => provider_id}, _session, socket) do - with {:ok, provider} <- - Auth.fetch_active_provider_by_id(provider_id, socket.assigns.subject, - preload: [created_by_identity: [:actor]] - ) do - {:ok, assign(socket, provider: provider)} - else - _ -> raise Web.LiveErrors.NotFoundError - end - end - - def render(assigns) do - ~H""" - <.breadcrumbs account={@account}> - <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> - Identity Providers Settings - - - <.breadcrumb path={ - ~p"/#{@account}/settings/identity_providers/saml/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - }> - <%= @provider.name %> - - - - <.section> - <:title> - Viewing Identity Provider <%= @provider.name %> - - <:action> - <.edit_button navigate={ - ~p"/#{@account}/settings/identity_providers/saml/#{@provider.id}/edit" - }> - Edit Identity Provider - - - - <:content> - <.header> - <:title>Details - - - <.flash_group flash={@flash} /> - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Name - - <%= @provider.name %> -
- Type - - SAML 2.0 -
- Sign requests - - Yes -
- Sign metadata - - Yes -
- Require signed assertions - - Yes -
- Require signed envelopes - - Yes -
- Base URL - - Yes -
- Created - - <.created_by account={@account} schema={@provider} /> -
-
- - - <.section> - <:title> - Danger zone - - <:action> - <.delete_button - data-confirm="Are you sure want to delete this provider along with all related data?" - phx-click="delete" - > - Delete Identity Provider - - - <:content> - - """ - end - - def handle_event("delete", _params, socket) do - {:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject) - - {:noreply, - push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")} - end -end diff --git a/elixir/apps/web/lib/web/live/sign_in.ex b/elixir/apps/web/lib/web/live/sign_in.ex index d8a1e4102..e5f444c66 100644 --- a/elixir/apps/web/lib/web/live/sign_in.ex +++ b/elixir/apps/web/lib/web/live/sign_in.ex @@ -2,40 +2,43 @@ defmodule Web.SignIn do use Web, {:live_view, layout: {Web.Layouts, :public}} alias Domain.{Auth, Accounts} - def mount(%{"account_id_or_slug" => account_id_or_slug} = params, session, socket) do + @root_adapters_whitelist [:email, :userpass, :openid_connect] + + def mount(%{"account_id_or_slug" => account_id_or_slug} = params, _session, socket) do with {:ok, account} <- Accounts.fetch_account_by_id_or_slug(account_id_or_slug), {:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do providers_by_adapter = providers - |> Enum.group_by(fn provider -> - parent_adapter = - provider - |> Auth.fetch_provider_capabilities!() - |> Keyword.get(:parent_adapter) + |> group_providers_by_root_adapter() + |> Map.take(@root_adapters_whitelist) - parent_adapter || provider.adapter - end) - |> Map.drop([:token]) + params = Web.Auth.take_sign_in_params(params) - query_params = take_non_empty_params(params, ["client_platform", "client_csrf_token"]) - session_params = take_non_empty_params(session, ["client_platform", "client_csrf_token"]) - params = Map.merge(session_params, query_params) + socket = + assign(socket, + params: params, + account: account, + providers_by_adapter: providers_by_adapter, + page_title: "Sign in" + ) - {:ok, socket, - temporary_assigns: [ - params: params, - account: account, - providers_by_adapter: providers_by_adapter, - page_title: "Sign in" - ]} + {:ok, socket} else _other -> raise Web.LiveErrors.NotFoundError end end - defp take_non_empty_params(map, keys) do - map |> Map.take(keys) |> Map.reject(fn {_key, value} -> value in ["", nil] end) + defp group_providers_by_root_adapter(providers) do + providers + |> Enum.group_by(fn provider -> + parent_adapter = + provider + |> Auth.fetch_provider_capabilities!() + |> Keyword.get(:parent_adapter) + + parent_adapter || provider.adapter + end) end def render(assigns) do @@ -99,7 +102,7 @@ defmodule Web.SignIn do
-
+

Meant to sign in from a client instead? diff --git a/elixir/apps/web/lib/web/live/sign_in/email.ex b/elixir/apps/web/lib/web/live/sign_in/email.ex index 9c28bded2..b1b12d9d3 100644 --- a/elixir/apps/web/lib/web/live/sign_in/email.ex +++ b/elixir/apps/web/lib/web/live/sign_in/email.ex @@ -7,26 +7,24 @@ defmodule Web.SignIn.Email do "provider_id" => provider_id, "provider_identifier" => provider_identifier } = params, - session, + _session, socket ) do form = to_form(%{"secret" => nil}) - query_params = Map.take(params, ["client_platform", "client_csrf_token"]) - session_params = Map.take(session, ["client_platform", "client_csrf_token"]) - params = Map.merge(session_params, query_params) + params = Web.Auth.take_sign_in_params(params) - {:ok, socket, - temporary_assigns: [ - form: form, - provider_identifier: provider_identifier, - account_id_or_slug: account_id_or_slug, - provider_id: provider_id, - resent: params["resent"], - redirect_params: params, - client_platform: params["client_platform"], - client_csrf_token: params["client_csrf_token"] - ]} + socket = + assign(socket, + form: form, + provider_identifier: provider_identifier, + account_id_or_slug: account_id_or_slug, + provider_id: provider_id, + resent: params["resent"], + params: params + ) + + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} end def render(assigns) do @@ -56,6 +54,7 @@ defmodule Web.SignIn.Email do method="get" class="my-4 flex" > + <.input :for={{key, value} <- @params} type="hidden" name={key} value={value} /> <.input type="hidden" name="identity_id" value={@provider_identifier} /> or - <.link navigate={~p"/#{@account_id_or_slug}?#{@redirect_params}"} class={[link_style()]}> + <.link navigate={~p"/#{@account_id_or_slug}?#{@params}"} class={link_style()}> use a different Sign In method . @@ -135,18 +133,7 @@ defmodule Web.SignIn.Email do method="post" > <.input type="hidden" name="email[provider_identifier]" value={@provider_identifier} /> - <.input - :if={not is_nil(@client_platform)} - type="hidden" - name="client_platform" - value={@client_platform} - /> - <.input - :if={not is_nil(@client_csrf_token)} - type="hidden" - name="client_csrf_token" - value={@client_csrf_token} - /> + <.input :for={{key, value} <- @params} type="hidden" name={key} value={value} /> Did not receive it?