From 7ea1718b12249e44145a323b1cadfe3f5dccfc75 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Tue, 17 Aug 2021 17:03:54 -0700 Subject: [PATCH 01/11] START - add ability to generate device_grant schema --- .gitignore | 2 + config/test.exs | 14 +- lib/ex_oauth2_provider/config.ex | 65 ++- .../device_grants/device_grant.ex | 93 ++++ .../device_grants/device_grants.ex | 114 ++++ .../oauth2/authorization.ex | 48 +- .../authorization/strategy/device_code.ex | 20 + .../device_code/device_authorization.ex | 88 +++ .../strategy/device_code/user_interaction.ex | 60 +++ .../oauth2/authorization/utils.ex | 22 +- lib/ex_oauth2_provider/oauth2/token.ex | 15 +- .../oauth2/token/strategy/device_code.ex | 151 ++++++ lib/ex_oauth2_provider/oauth2/token/utils.ex | 16 +- .../oauth2/utils/device_flow.ex | 26 + lib/ex_oauth2_provider/oauth2/utils/error.ex | 34 +- .../oauth2/utils/validation.ex | 60 +++ lib/mix/ex_oauth2_provider/migration.ex | 67 ++- lib/mix/ex_oauth2_provider/schema.ex | 59 ++- .../tasks/ex_oauth2_provider.gen.migration.ex | 11 +- .../tasks/ex_oauth2_provider.gen.schemas.ex | 34 +- .../device_code/device_authorization_test.exs | 171 ++++++ .../device_code/user_interaction_test.exs | 139 +++++ .../strategy/device_code_test.exs | 54 ++ .../token/strategy/device_code_test.exs | 501 ++++++++++++++++++ .../oauth2/utils/device_flow_test.exs | 85 +++ .../ex_oauth2_provider.gen.migration_test.exs | 67 ++- .../ex_oauth2_provider.gen.schemas_test.exs | 74 ++- test/support/fixtures.ex | 42 +- .../oauth_device_grants/oauth_device_grant.ex | 16 + .../priv/migrations/2_create_oauth_tables.exs | 7 +- test/support/query_helpers.ex | 4 + 31 files changed, 2040 insertions(+), 119 deletions(-) create mode 100644 lib/ex_oauth2_provider/device_grants/device_grant.ex create mode 100644 lib/ex_oauth2_provider/device_grants/device_grants.ex create mode 100644 lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex create mode 100644 lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex create mode 100644 lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex create mode 100644 lib/ex_oauth2_provider/oauth2/token/strategy/device_code.ex create mode 100644 lib/ex_oauth2_provider/oauth2/utils/device_flow.ex create mode 100644 lib/ex_oauth2_provider/oauth2/utils/validation.ex create mode 100644 test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs create mode 100644 test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction_test.exs create mode 100644 test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs create mode 100644 test/ex_oauth2_provider/oauth2/token/strategy/device_code_test.exs create mode 100644 test/ex_oauth2_provider/oauth2/utils/device_flow_test.exs create mode 100644 test/support/lib/dummy/oauth_device_grants/oauth_device_grant.ex diff --git a/.gitignore b/.gitignore index ccbc8af5..b44d958b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ erl_crash.dump /tmp .DS_Store /.elixir_ls +*.swp +.tool-versions diff --git a/config/test.exs b/config/test.exs index fdc97b11..81ac41ca 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,15 +1,21 @@ use Mix.Config config :ex_oauth2_provider, namespace: Dummy + config :ex_oauth2_provider, ExOauth2Provider, - repo: Dummy.Repo, - resource_owner: Dummy.Users.User, default_scopes: ~w(public), + device_flow_verification_uri: "https://test.site.net/device", + grant_flows: ~w( + authorization_code + client_credentials + device_code + ), optional_scopes: ~w(read write), password_auth: {Dummy.Auth, :auth}, - use_refresh_token: true, + repo: Dummy.Repo, + resource_owner: Dummy.Users.User, revoke_refresh_token_on_use: true, - grant_flows: ~w(authorization_code client_credentials) + use_refresh_token: true config :ex_oauth2_provider, Dummy.Repo, database: "ex_oauth2_provider_test", diff --git a/lib/ex_oauth2_provider/config.ex b/lib/ex_oauth2_provider/config.ex index da8ca43e..c7a01712 100644 --- a/lib/ex_oauth2_provider/config.ex +++ b/lib/ex_oauth2_provider/config.ex @@ -3,12 +3,13 @@ defmodule ExOauth2Provider.Config do @spec repo(keyword()) :: module() def repo(config) do - get(config, :repo) || raise """ + get(config, :repo) || + raise """ No `:repo` found in ExOauth2Provider configuration. Please set up the repo in your configuration: - config #{inspect Keyword.get(config, :otp_app, :ex_oauth2_provider)}, ExOauth2Provider, + config #{inspect(Keyword.get(config, :otp_app, :ex_oauth2_provider))}, ExOauth2Provider, repo: MyApp.Repo """ end @@ -21,7 +22,9 @@ defmodule ExOauth2Provider.Config do app = config |> Keyword.get(:otp_app) - |> Kernel.||(raise "No `:otp_app` found in provided configuration. Please pass `:otp_app` in configuration.") + |> Kernel.||( + raise "No `:otp_app` found in provided configuration. Please pass `:otp_app` in configuration." + ) |> app_base() Module.concat([app, context, module]) @@ -39,9 +42,13 @@ defmodule ExOauth2Provider.Config do def application(config), do: get_oauth_struct(config, :application) + @spec device_grant(keyword()) :: module() + def device_grant(config), + do: get_oauth_struct(config, :device_grant) + defp get_oauth_struct(config, name, namespace \\ "oauth") do context = Macro.camelize("#{namespace}_#{name}s") - module = Macro.camelize("#{namespace}_#{name}") + module = Macro.camelize("#{namespace}_#{name}") config |> get(name) @@ -117,7 +124,7 @@ defmodule ExOauth2Provider.Config do # wise to keep this enabled. @spec force_ssl_in_redirect_uri?(keyword()) :: boolean() def force_ssl_in_redirect_uri?(config), - do: get(config, :force_ssl_in_redirect_uri, unquote(Mix.env != :dev)) + do: get(config, :force_ssl_in_redirect_uri, unquote(Mix.env() != :dev)) # Use a custom access token generator @spec access_token_generator(keyword()) :: {atom(), atom()} | nil @@ -129,8 +136,47 @@ defmodule ExOauth2Provider.Config do do: get(config, :access_token_response_body_handler) @spec grant_flows(keyword()) :: [binary()] - def grant_flows(config), - do: get(config, :grant_flows, ~w(authorization_code client_credentials)) + def grant_flows(config) do + flows = get(config, :grant_flows, ~w(authorization_code client_credentials)) + + case Enum.member?(flows, "device_code") do + # Device flow requires grant type to be this for the token request. + # Adding it in bound to the device code token strategy allows this to work + # but also allows the configuration to only need "device_code" to enable. + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + true -> Enum.concat(flows, ["urn:ietf:params:oauth:grant-type:device_code"]) + false -> flows + end + end + + @spec device_flow_device_code_length(keyword()) :: [integer()] + def device_flow_device_code_length(config), + do: get(config, :device_flow_device_code_length, 32) + + @spec device_flow_polling_interval(keyword()) :: [integer()] + def device_flow_polling_interval(config), + do: get(config, :device_flow_polling_interval, 5) + + @spec device_flow_user_code_base(keyword()) :: [integer()] + def device_flow_user_code_base(config), + do: get(config, :device_flow_user_code_base, 36) + + @spec device_flow_user_code_length(keyword()) :: [integer()] + def device_flow_user_code_length(config), + do: get(config, :device_flow_user_code_length, 8) + + @spec device_flow_verification_uri(keyword()) :: [binary()] + def device_flow_verification_uri(config), + do: + get(config, :device_flow_verification_uri) || + raise(""" + `:device_flow_verification_uri` is required to support the device flow. + + Please update your configuration with the uri your application uses to verify devices: + + config #{inspect(Keyword.get(config, :otp_app, :ex_oauth2_provider))}, ExOauth2Provider, + device_flow_verification_uri: "https://really.cool.site/device" + """) defp get(config, key, value \\ nil) do otp_app = Keyword.get(config, :otp_app) @@ -141,18 +187,20 @@ defmodule ExOauth2Provider.Config do |> get_from_global_env(key) |> case do :not_found -> value - value -> value + value -> value end end defp get_from_config(config, key), do: Keyword.get(config, key, :not_found) defp get_from_app_env(:not_found, nil, _key), do: :not_found + defp get_from_app_env(:not_found, otp_app, key) do otp_app |> Application.get_env(ExOauth2Provider, []) |> Keyword.get(key, :not_found) end + defp get_from_app_env(value, _otp_app, _key), do: value defp get_from_global_env(:not_found, key) do @@ -160,5 +208,6 @@ defmodule ExOauth2Provider.Config do |> Application.get_env(ExOauth2Provider, []) |> Keyword.get(key, :not_found) end + defp get_from_global_env(value, _key), do: value end diff --git a/lib/ex_oauth2_provider/device_grants/device_grant.ex b/lib/ex_oauth2_provider/device_grants/device_grant.ex new file mode 100644 index 00000000..e66a3c1d --- /dev/null +++ b/lib/ex_oauth2_provider/device_grants/device_grant.ex @@ -0,0 +1,93 @@ +defmodule ExOauth2Provider.DeviceGrants.DeviceGrant do + @moduledoc """ + Handles the Ecto schema for device grant. + + ## Usage + + Configure `lib/my_project/oauth_device_grants/oauth_device_grant.ex` the following way: + + defmodule MyApp.OauthDeviceGrants.OauthDeviceGrant do + use Ecto.Schema + use ExOauth2Provider.DeviceGrants.DeviceGrant + + schema "oauth_device_grants" do + device_grant_fields() + + timestamps() + end + end + """ + + @type t :: Ecto.Schema.t() + + @doc false + def attrs() do + [ + {:device_code, :string, null: false}, + {:expires_in, :integer, null: false}, + {:last_polled_at, :utc_datetime}, + {:scopes, :string}, + {:user_code, :string} + ] + end + + @doc false + def assocs() do + [ + {:belongs_to, :application, :applications}, + {:belongs_to, :resource_owner, :users} + ] + end + + @doc false + def indexes() do + [ + {:device_code, true}, + {:user_code, true} + ] + end + + defmacro __using__(config) do + quote do + use ExOauth2Provider.Schema, unquote(config) + + import unquote(__MODULE__), only: [device_grant_fields: 0] + end + end + + defmacro device_grant_fields do + quote do + ExOauth2Provider.Schema.fields(unquote(__MODULE__)) + end + end + + alias Ecto.Changeset + alias ExOauth2Provider.{Mixin.Scopes, Utils} + + @spec changeset(Ecto.Schema.t(), map(), keyword()) :: Changeset.t() + def changeset(grant, params, config) do + grant + |> Changeset.cast( + params, + [ + :device_code, + :expires_in, + :last_polled_at, + :resource_owner_id, + :scopes, + :user_code + ] + ) + |> Changeset.assoc_constraint(:application) + |> Changeset.assoc_constraint(:resource_owner) + |> Scopes.put_scopes(grant.application.scopes, config) + |> Scopes.validate_scopes(grant.application.scopes, config) + |> Changeset.validate_required([ + :device_code, + :expires_in, + :application + ]) + |> Changeset.unique_constraint(:device_code) + |> Changeset.unique_constraint(:user_code) + end +end diff --git a/lib/ex_oauth2_provider/device_grants/device_grants.ex b/lib/ex_oauth2_provider/device_grants/device_grants.ex new file mode 100644 index 00000000..43490a0d --- /dev/null +++ b/lib/ex_oauth2_provider/device_grants/device_grants.ex @@ -0,0 +1,114 @@ +defmodule ExOauth2Provider.DeviceGrants do + @moduledoc """ + The boundary for the OauthDeviceGrants system. + """ + + import Ecto.Query + alias ExOauth2Provider.Mixin.{Expirable} + + alias ExOauth2Provider.{ + Applications.Application, + DeviceGrants.DeviceGrant, + Config + } + + defdelegate is_expired?(device_grant), to: Expirable + + def authorize(device_grant, resource_owner, config) do + params = %{resource_owner_id: resource_owner.id, user_code: nil} + + device_grant + |> DeviceGrant.changeset(params, config) + |> get_repo(config).update() + end + + @doc """ + Creates a device grant. + + ## Examples + + iex> create_grant(application, attrs) + {:ok, %OauthDeviceGrant{}} + + iex> create_grant(application, attrs) + {:error, %Ecto.Changeset{}} + + """ + @spec create_grant(Application.t(), map(), keyword()) :: + {:ok, DeviceGrant.t()} | {:error, term()} + def create_grant(application, attrs, config \\ []) do + {schema, repo} = schema_and_repo_from_config(config) + + schema + |> struct(application: application) + |> DeviceGrant.changeset(attrs, config) + |> repo.insert() + end + + def delete_expired(config) do + {schema, repo} = schema_and_repo_from_config(config) + lifespan = Config.authorization_code_expires_in(config) + + from( + d in schema, + where: d.inserted_at <= ago(^lifespan, "second") + ) + |> repo.delete_all() + end + + def delete!(grant, config) do + get_repo(config).delete!(grant) + end + + @doc """ + Gets a single device grant registered with an application. + + ## Examples + + iex> find_by_application_and_device_code(application, "jE9dk", otp_app: :my_app) + %OauthDeviceGrant{} + + iex> find_by_application_and_device_code(application, "jE9dk", otp_app: :my_app) + ** nil + + """ + @spec find_by_application_and_device_code(Application.t(), binary(), keyword()) :: + DeviceGrant.t() | nil + def find_by_application_and_device_code(application, device_code, config \\ []) do + {schema, repo} = schema_and_repo_from_config(config) + repo.get_by(schema, application_id: application.id, device_code: device_code) + end + + def find_by_user_code(nil, _config), do: nil + + # DeviceGrant | nil + def find_by_user_code(user_code, config) do + config + |> schema_and_repo_from_config() + |> fetch_grant_with_user_code(user_code) + end + + def update_last_polled_at!(grant, config) do + grant + |> DeviceGrant.changeset(%{last_polled_at: DateTime.utc_now()}, config) + |> get_repo(config).update!() + end + + defp device_grant_schema(config), do: Config.device_grant(config) + + defp fetch_grant_with_user_code({schema, repo}, user_code) do + # from(d in schema, preload: [:application], where: d.user_code == ^user_code) + schema + |> repo.get_by(user_code: user_code) + |> repo.preload(:application) + end + + defp get_repo(config), do: Config.repo(config) + + defp schema_and_repo_from_config(config) do + { + Config.device_grant(config), + Config.repo(config) + } + end +end diff --git a/lib/ex_oauth2_provider/oauth2/authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization.ex index b470dd1e..9d40ce93 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization.ex @@ -6,30 +6,44 @@ defmodule ExOauth2Provider.Authorization do Authorization.Utils, Authorization.Utils.Response, Config, - Utils.Error} + Utils.Error + } + alias Ecto.Schema @doc """ Check ExOauth2Provider.Authorization.Code for usage. """ - @spec preauthorize(Schema.t(), map(), keyword()) :: Response.success() | Response.error() | Response.redirect() | Response.native_redirect() + @spec preauthorize(Schema.t(), map(), keyword()) :: + Response.success() | Response.error() | Response.redirect() | Response.native_redirect() def preauthorize(resource_owner, request, config \\ []) do case validate_response_type(request, config) do - {:error, :invalid_response_type} -> unsupported_response_type(resource_owner, request, config) - {:error, :missing_response_type} -> invalid_request(resource_owner, request, config) - {:ok, token_module} -> token_module.preauthorize(resource_owner, request, config) + {:error, :invalid_response_type} -> + unsupported_response_type(resource_owner, request, config) + + {:error, :missing_response_type} -> + invalid_request(resource_owner, request, config) + + {:ok, token_module} -> + token_module.preauthorize(resource_owner, request, config) end end @doc """ Check ExOauth2Provider.Authorization.Code for usage. """ - @spec authorize(Schema.t(), map(), keyword()) :: {:ok, binary()} | Response.error() | Response.redirect() | Response.native_redirect() + @spec authorize(Schema.t(), map(), keyword()) :: + {:ok, binary()} | Response.error() | Response.redirect() | Response.native_redirect() def authorize(resource_owner, request, config \\ []) do case validate_response_type(request, config) do - {:error, :invalid_response_type} -> unsupported_response_type(resource_owner, request, config) - {:error, :missing_response_type} -> invalid_request(resource_owner, request, config) - {:ok, token_module} -> token_module.authorize(resource_owner, request, config) + {:error, :invalid_response_type} -> + unsupported_response_type(resource_owner, request, config) + + {:error, :missing_response_type} -> + invalid_request(resource_owner, request, config) + + {:ok, token_module} -> + token_module.authorize(resource_owner, request, config) end end @@ -39,9 +53,14 @@ defmodule ExOauth2Provider.Authorization do @spec deny(Schema.t(), map(), keyword()) :: Response.error() | Response.redirect() def deny(resource_owner, request, config \\ []) do case validate_response_type(request, config) do - {:error, :invalid_response_type} -> unsupported_response_type(resource_owner, request, config) - {:error, :missing_response_type} -> invalid_request(resource_owner, request, config) - {:ok, token_module} -> token_module.deny(resource_owner, request, config) + {:error, :invalid_response_type} -> + unsupported_response_type(resource_owner, request, config) + + {:error, :missing_response_type} -> + invalid_request(resource_owner, request, config) + + {:ok, token_module} -> + token_module.deny(resource_owner, request, config) end end @@ -67,9 +86,11 @@ defmodule ExOauth2Provider.Authorization do mod -> {:ok, mod} end end + defp validate_response_type(_, _config), do: {:error, :missing_response_type} defp response_type_to_grant_flow("code"), do: "authorization_code" + defp response_type_to_grant_flow("device_code"), do: "device_code" defp response_type_to_grant_flow(_), do: nil defp fetch_module(grant_flow, config) do @@ -77,7 +98,7 @@ defmodule ExOauth2Provider.Authorization do |> Config.grant_flows() |> flow_can_be_used?(grant_flow) |> case do - true -> flow_to_mod(grant_flow) + true -> flow_to_mod(grant_flow) false -> nil end end @@ -87,5 +108,6 @@ defmodule ExOauth2Provider.Authorization do end defp flow_to_mod("authorization_code"), do: ExOauth2Provider.Authorization.Code + defp flow_to_mod("device_code"), do: ExOauth2Provider.Authorization.DeviceCode defp flow_to_mod(_), do: nil end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex new file mode 100644 index 00000000..152ad417 --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex @@ -0,0 +1,20 @@ +defmodule ExOauth2Provider.Authorization.DeviceCode do + alias ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization + alias ExOauth2Provider.Authorization.DeviceCode.UserInteraction + + # NOTE: This module is the glue for the various steps and integrates it into + # the authorization architecture that this package provides. It separates + # the concerns to simplify things. + + # User Interaction Request - approve the grant with user code + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + def authorize(resource_owner, request, config \\ []) do + UserInteraction.process_request(resource_owner, request, config) + end + + # Device Authorization Request + # https://tools.ietf.org/html/rfc8628#section-3.1 + def preauthorize(_resource_owner, request, config \\ []) do + DeviceAuthorization.process_request(request, config) + end +end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex new file mode 100644 index 00000000..07209605 --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex @@ -0,0 +1,88 @@ +defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization do + alias ExOauth2Provider.{ + Config, + DeviceGrants, + Authorization.Utils, + Scopes, + Utils.DeviceFlow, + Utils.Error, + Utils.Validation + } + + @required_params ~w(client_id) + + # Device Authorization Request + # https://tools.ietf.org/html/rfc8628#section-3.1 + def process_request(request, config \\ []) do + %{config: config, request: request} + |> Validation.validate_required_query_params(@required_params) + |> load_application() + |> Validation.validate_scope() + |> issue_grant() + |> send_response() + end + + def send_response({:ok, %{config: config, grant: grant}}) do + payload = %{ + device_code: grant.device_code, + expires_in: grant.expires_in, + interval: Config.device_flow_polling_interval(config), + user_code: grant.user_code, + verification_uri: Config.device_flow_verification_uri(config) + } + + {:ok, payload} + end + + def send_response({:error, %{error: error, error_http_status: http_status}}) do + {:error, error, http_status} + end + + def send_response({:error, error, http_status}) do + {:error, error, http_status} + end + + defp generate_grant_params(request, config) do + %{ + device_code: DeviceFlow.generate_device_code(), + expires_in: Config.authorization_code_expires_in(config), + scopes: Map.get(request, "scope"), + user_code: DeviceFlow.generate_user_code() + } + end + + defp issue_grant({:ok, context}) do + %{client: application, config: config, request: request} = context + + # This is just basic cleanup. + DeviceGrants.delete_expired(config) + + grant_params = generate_grant_params(request, config) + + case DeviceGrants.create_grant(application, grant_params, config) do + {:ok, grant} -> {:ok, Map.put(context, :grant, grant)} + {:error, error} -> Error.add_error({:ok, context}, error) + end + end + + defp issue_grant(error_response), do: error_response + + defp load_application({:ok, %{config: config, request: request}}) do + # There is no resource owner on this request so we explicitly pass nil. + # This pre-handler is constructing a new map based on request so we need + # to stitch back in config. Since we pre-validate the required params + # and have a context before this runs it's easier this way until it can be + # refactored to work on a pre-made context. + {ok_or_error, context} = + Utils.prehandle_request( + nil, + request, + config, + error_http_status: :unauthorized + ) + + {ok_or_error, Map.put(context, :config, config)} + end + + defp load_application(error_response), do: error_response +end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex new file mode 100644 index 00000000..f6a9064a --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex @@ -0,0 +1,60 @@ +defmodule ExOauth2Provider.Authorization.DeviceCode.UserInteraction do + alias Ecto.Changeset + alias ExOauth2Provider.DeviceGrants + + @message_lookup [ + expired_user_code: "The user_code has expired.", + invalid_owner: "The given owner is invalid.", + invalid_user_code: "The user_code is invalid.", + user_code_missing: "The request is missing the required param user_code." + ] + @status_lookup [ + expired_user_code: :unprocessable_entity, + invalid_owner: :unprocessable_entity, + invalid_user_code: :unprocessable_entity, + user_code_missing: :bad_request + ] + + # User Interaction Request - approve the grant with user code + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + def process_request(owner, request, config \\ []) do + %{config: config, owner: owner, user_code: Map.get(request, "user_code")} + |> find_device_grant() + |> authorize() + |> send_response() + end + + defp authorize({:error, _code} = error), do: error + defp authorize(%{grant: nil}), do: {:error, :invalid_user_code} + + defp authorize(%{config: config, grant: grant, owner: owner}) do + if DeviceGrants.is_expired?(grant) do + {:error, :expired_user_code} + else + DeviceGrants.authorize(grant, owner, config) + end + end + + defp find_device_grant(%{user_code: nil}) do + {:error, :user_code_missing} + end + + defp find_device_grant(%{config: config, user_code: user_code} = context) do + Map.put(context, :grant, DeviceGrants.find_by_user_code(user_code, config)) + end + + defp send_response({:error, %Changeset{}}) do + send_response({:error, :invalid_owner}) + end + + defp send_response({:error, code}) do + message = Keyword.get(@message_lookup, code) + status = Keyword.get(@status_lookup, code) + + {:error, %{error: code, error_description: message}, status} + end + + defp send_response({:ok, device_grant}) do + {:ok, device_grant} + end +end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/utils.ex b/lib/ex_oauth2_provider/oauth2/authorization/utils.ex index 998c0beb..61cec351 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/utils.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/utils.ex @@ -6,10 +6,10 @@ defmodule ExOauth2Provider.Authorization.Utils do @doc false @spec prehandle_request(Schema.t(), map(), keyword()) :: {:ok, map()} | {:error, map()} - def prehandle_request(resource_owner, request, config) do + def prehandle_request(resource_owner, request, config, opts \\ []) do resource_owner |> new_params(request) - |> load_client(config) + |> load_client(config, opts) |> set_defaults() end @@ -17,22 +17,26 @@ defmodule ExOauth2Provider.Authorization.Utils do {:ok, %{resource_owner: resource_owner, request: request}} end - defp load_client({:ok, %{request: %{"client_id" => client_id}} = params}, config) do + defp load_client({:ok, %{request: %{"client_id" => client_id}} = params}, config, opts) do case Applications.get_application(client_id, config) do - nil -> Error.add_error({:ok, params}, Error.invalid_client()) + nil -> Error.add_error({:ok, params}, Error.invalid_client(opts)) client -> {:ok, Map.put(params, :client, client)} end end - defp load_client({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_request()) + + defp load_client({:ok, params}, _config, _opts), + do: Error.add_error({:ok, params}, Error.invalid_request()) defp set_defaults({:error, params}), do: {:error, params} + defp set_defaults({:ok, %{request: request, client: client} = params}) do [redirect_uri | _rest] = String.split(client.redirect_uri) - request = Map.new() - |> Map.put("redirect_uri", redirect_uri) - |> Map.put("scope", nil) - |> Map.merge(request) + request = + Map.new() + |> Map.put("redirect_uri", redirect_uri) + |> Map.put("scope", nil) + |> Map.merge(request) {:ok, Map.put(params, :request, request)} end diff --git a/lib/ex_oauth2_provider/oauth2/token.ex b/lib/ex_oauth2_provider/oauth2/token.ex index a47bce74..4d1c4365 100644 --- a/lib/ex_oauth2_provider/oauth2/token.ex +++ b/lib/ex_oauth2_provider/oauth2/token.ex @@ -5,7 +5,9 @@ defmodule ExOauth2Provider.Token do alias ExOauth2Provider.{ Config, Token.Revoke, - Utils.Error} + Utils.Error + } + alias Ecto.Schema @doc """ @@ -28,7 +30,7 @@ defmodule ExOauth2Provider.Token do case validate_grant_type(request, config) do {:error, :invalid_grant_type} -> Error.unsupported_grant_type() {:error, :missing_grant_type} -> Error.invalid_request() - {:ok, token_module} -> token_module.grant(request, config) + {:ok, token_module} -> token_module.grant(request, config) end end @@ -40,6 +42,7 @@ defmodule ExOauth2Provider.Token do mod -> {:ok, mod} end end + defp validate_grant_type(_, _config), do: {:error, :missing_grant_type} defp fetch_module(type, config) do @@ -47,15 +50,17 @@ defmodule ExOauth2Provider.Token do |> Config.grant_flows() |> grant_type_can_be_used?(type, config) |> case do - true -> grant_type_to_mod(type) + true -> grant_type_to_mod(type) false -> nil end end defp grant_type_can_be_used?(_, "refresh_token", config), do: Config.use_refresh_token?(config) + defp grant_type_can_be_used?(_, "password", config), do: not is_nil(Config.password_auth(config)) + defp grant_type_can_be_used?(grant_flows, grant_type, _config) do Enum.member?(grant_flows, grant_type) end @@ -64,6 +69,10 @@ defmodule ExOauth2Provider.Token do defp grant_type_to_mod("client_credentials"), do: ExOauth2Provider.Token.ClientCredentials defp grant_type_to_mod("password"), do: ExOauth2Provider.Token.Password defp grant_type_to_mod("refresh_token"), do: ExOauth2Provider.Token.RefreshToken + + defp grant_type_to_mod("urn:ietf:params:oauth:grant-type:device_code"), + do: ExOauth2Provider.Token.DeviceCode + defp grant_type_to_mod(_), do: nil @doc """ diff --git a/lib/ex_oauth2_provider/oauth2/token/strategy/device_code.ex b/lib/ex_oauth2_provider/oauth2/token/strategy/device_code.ex new file mode 100644 index 00000000..a6b758b9 --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/token/strategy/device_code.ex @@ -0,0 +1,151 @@ +defmodule ExOauth2Provider.Token.DeviceCode do + alias ExOauth2Provider.{AccessTokens, Config, DeviceGrants} + alias ExOauth2Provider.Token.Utils + alias ExOauth2Provider.Utils.{Error, Validation} + + @required_params ~w(client_id device_code) + + # Device Access Token Request + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + # + # NOTE: http error status for invalid_client in a token response should be + # 401, not 422 which is the value provided in Error#invalid_client. + # This overrides that so we get the expected behavior. + # https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ + def grant(request, config \\ []) do + %{config: config, request: request} + |> Validation.validate_required_query_params(@required_params) + |> Utils.load_client(config, error_http_status: :unauthorized) + |> Validation.validate_scope() + |> load_grant() + |> check_expiration() + |> check_polling_rate() + |> check_authorization() + |> create_token() + |> send_response() + end + + defp send_response({:ok, %{access_token: access_token}}) do + { + :ok, + %{ + access_token: access_token.token, + expires: access_token.expires_in, + refresh_token: access_token.refresh_token, + scope: access_token.scopes, + token_type: "bearer" + } + } + end + + defp send_response(error_response), do: error_response + + defp create_token({:ok, context}) do + %{config: config, grant: grant} = context + + result = + Config.repo(config).transaction(fn -> + grant + |> DeviceGrants.delete!(config) + |> find_or_create_token(context) + end) + + case result do + {:ok, {:error, error}} -> Error.add_error({:ok, context}, error) + {:ok, {:ok, access_token}} -> {:ok, Map.put(context, :access_token, access_token)} + {:error, error} -> Error.add_error({:ok, context}, error) + end + end + + defp create_token(error_response), do: error_response + + defp check_authorization({:ok, %{grant: %{user_code: nil}} = context}) do + {:ok, context} + end + + defp check_authorization({:ok, %{grant: %{user_code: _user_code}}}) do + {:error, %{error: :authorization_pending}, :bad_request} + end + + defp check_authorization(error_response), do: error_response + + defp check_expiration({:ok, %{config: config, grant: grant} = context}) do + too_old = + DateTime.utc_now() + |> DateTime.add(-grant.expires_in, :second) + |> DateTime.truncate(:second) + + inserted_at = DateTime.from_naive!(grant.inserted_at, "Etc/UTC") + + case inserted_at > too_old do + false -> + DeviceGrants.delete!(grant, config) + {:error, %{error: :expired_token}, :bad_request} + + true -> + {:ok, context} + end + end + + defp check_expiration(error_response), do: error_response + + defp check_polling_rate({:ok, %{config: config, grant: grant} = context}) do + # NOTE: The DeviceGrant struct has seconds precision. + age_limit = + DateTime.utc_now() + |> DateTime.add(-Config.device_flow_polling_interval(config), :second) + |> DateTime.truncate(:second) + + too_fast = grant.last_polled_at && grant.last_polled_at >= age_limit + + DeviceGrants.update_last_polled_at!(grant, config) + + case too_fast do + true -> {:error, %{error: :slow_down}, :bad_request} + nil -> {:ok, context} + false -> {:ok, context} + end + end + + defp check_polling_rate(error_response), do: error_response + + defp find_or_create_token(deleted_grant, context) do + %{client: application, config: config, request: request} = context + scopes = Map.get(request, "scope", nil) + + token_params = %{ + application: application, + scopes: scopes, + use_refresh_token: Config.use_refresh_token?(config) + } + + deleted_grant.resource_owner + |> AccessTokens.get_token_for(application, scopes, config) + |> case do + nil -> AccessTokens.create_token(deleted_grant.resource_owner, token_params, config) + access_token -> {:ok, access_token} + end + end + + defp load_grant({:ok, context}) do + %{client: client, config: config, request: request} = context + device_code = Map.get(request, "device_code") + + client + |> DeviceGrants.find_by_application_and_device_code(device_code, config) + |> Config.repo(config).preload(:application) + |> Config.repo(config).preload(:resource_owner) + |> case do + nil -> Error.invalid_grant(:bad_request) + grant -> {:ok, Map.put(context, :grant, grant)} + end + end + + # NOTE: This is from Utils.load_client failure. + # It joins the status to the context. + defp load_grant({:error, %{error: error, error_http_status: status}}) do + {:error, error, status} + end + + defp load_grant({:error, error, status}), do: {:error, error, status} +end diff --git a/lib/ex_oauth2_provider/oauth2/token/utils.ex b/lib/ex_oauth2_provider/oauth2/token/utils.ex index 7fef0456..bc3e9181 100644 --- a/lib/ex_oauth2_provider/oauth2/token/utils.ex +++ b/lib/ex_oauth2_provider/oauth2/token/utils.ex @@ -5,14 +5,22 @@ defmodule ExOauth2Provider.Token.Utils do @doc false @spec load_client({:ok, map()}, keyword()) :: {:ok, map()} | {:error, map()} - def load_client({:ok, %{request: request = %{"client_id" => client_id}} = params}, config) do + def load_client( + {:ok, %{request: request = %{"client_id" => client_id}} = params}, + config, + opts \\ [] + ) do client_secret = Map.get(request, "client_secret", "") case Applications.load_application(client_id, client_secret, config) do - nil -> Error.add_error({:ok, params}, Error.invalid_client()) + nil -> Error.add_error({:ok, params}, Error.invalid_client(opts)) client -> {:ok, Map.merge(params, %{client: client})} end end - def load_client({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_request()) - def load_client({:error, params}, _config), do: {:error, params} + + def load_client({:ok, params}, _config, _ops), + do: Error.add_error({:ok, params}, Error.invalid_request()) + + def load_client({:error, params}, _config, _opts), do: {:error, params} + def load_client({:error, params, status}, _config, _opts), do: {:error, params, status} end diff --git a/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex b/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex new file mode 100644 index 00000000..9d3d0199 --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex @@ -0,0 +1,26 @@ +defmodule ExOauth2Provider.Utils.DeviceFlow do + alias ExOauth2Provider.Config + + def generate_device_code(config \\ [otp_app: :ex_oauth2_provider]) do + config + |> Config.device_flow_device_code_length() + |> :crypto.strong_rand_bytes() + |> Base.url_encode64() + end + + def generate_user_code(config \\ [otp_app: :ex_oauth2_provider]) do + # NOTE: Integer.pow only exists in elixir 1.12+ + # So we have to convert erlangs pow response to integer + # Thanks to Doorkeeper for this! + max_length = Config.device_flow_user_code_length(config) + base = Config.device_flow_user_code_base(config) + + base + |> :math.pow(max_length) + |> trunc() + |> :rand.uniform() + |> Integer.to_string(base) + |> String.upcase() + |> String.pad_leading(max_length, "0") + end +end diff --git a/lib/ex_oauth2_provider/oauth2/utils/error.ex b/lib/ex_oauth2_provider/oauth2/utils/error.ex index 02bb83f7..5e37fafa 100644 --- a/lib/ex_oauth2_provider/oauth2/utils/error.ex +++ b/lib/ex_oauth2_provider/oauth2/utils/error.ex @@ -4,35 +4,47 @@ defmodule ExOauth2Provider.Utils.Error do @doc false @spec add_error({:ok, map()} | {:error, map()}, {:error, map(), atom()}) :: {:error, map()} def add_error({:error, params}, _), do: {:error, params} + def add_error({:ok, params}, {:error, error, http_status}) do {:error, Map.merge(params, %{error: error, error_http_status: http_status})} end @spec server_error() :: {:error, map(), atom()} def server_error do - msg = "The authorization server encountered an unexpected condition which prevented it from fulfilling the request." + msg = + "The authorization server encountered an unexpected condition which prevented it from fulfilling the request." + {:error, %{error: :internal_server_error, error_description: msg}, :internal_server_error} end @doc false @spec invalid_request() :: {:error, map(), atom()} - def invalid_request do - msg = "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." + def invalid_request(msg \\ nil) do + msg = + msg || + "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." + {:error, %{error: :invalid_request, error_description: msg}, :bad_request} end @doc false @spec invalid_client() :: {:error, map(), atom()} - def invalid_client do - msg = "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." - {:error, %{error: :invalid_client, error_description: msg}, :unprocessable_entity} + def invalid_client(options \\ []) do + status = Keyword.get(options, :error_http_status, :unprocessable_entity) + + msg = + "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." + + {:error, %{error: :invalid_client, error_description: msg}, status} end @doc false @spec invalid_grant() :: {:error, map(), atom()} - def invalid_grant do - msg = "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client." - {:error, %{error: :invalid_grant, error_description: msg}, :unprocessable_entity} + def invalid_grant(status \\ :unprocessable_entity) do + msg = + "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client." + + {:error, %{error: :invalid_grant, error_description: msg}, status} end @doc false @@ -44,9 +56,9 @@ defmodule ExOauth2Provider.Utils.Error do @doc false @spec invalid_scopes() :: {:error, map(), atom()} - def invalid_scopes do + def invalid_scopes(status \\ :unprocessable_entity) do msg = "The requested scope is invalid, unknown, or malformed." - {:error, %{error: :invalid_scope, error_description: msg}, :unprocessable_entity} + {:error, %{error: :invalid_scope, error_description: msg}, status} end @doc false diff --git a/lib/ex_oauth2_provider/oauth2/utils/validation.ex b/lib/ex_oauth2_provider/oauth2/utils/validation.ex new file mode 100644 index 00000000..e4ec7094 --- /dev/null +++ b/lib/ex_oauth2_provider/oauth2/utils/validation.ex @@ -0,0 +1,60 @@ +defmodule ExOauth2Provider.Utils.Validation do + alias ExOauth2Provider.Scopes + alias ExOauth2Provider.Utils.Error + + def validate_request({:ok, %{request: %{"scope" => scopes}, client: client} = params}, config) do + scopes = Scopes.to_list(scopes) + + server_scopes = + client.scopes + |> Scopes.to_list() + |> Scopes.default_to_server_scopes(config) + + case Scopes.all?(server_scopes, scopes) do + true -> {:ok, params} + false -> Error.add_error({:ok, params}, Error.invalid_scopes()) + end + end + + def validate_request(error_response, _config), do: error_response + + def validate_required_query_params(%{request: request} = context, param_names) do + missing = + Enum.reject( + param_names, + fn name -> name in Map.keys(request) end + ) + + case missing do + [] -> + {:ok, context} + + missing -> + Error.invalid_request("Missing required param #{Enum.join(missing, ", ")}") + end + end + + # This can't be included in the required params because it simply checks + # presence and is done before you try to load any records. This requires + # the client_id and the record to be loaded already. + def validate_scope({:ok, context}) do + %{client: application, config: config, request: request} = context + + scopes = + request + |> Map.get("scope") + |> Scopes.to_list() + + server_scopes = + application.scopes + |> Scopes.to_list() + |> Scopes.default_to_server_scopes(config) + + case Scopes.all?(server_scopes, scopes) do + true -> {:ok, context} + false -> Error.invalid_scopes(:bad_request) + end + end + + def validate_scope(error_response), do: error_response +end diff --git a/lib/mix/ex_oauth2_provider/migration.ex b/lib/mix/ex_oauth2_provider/migration.ex index 76e876c3..f909e40a 100644 --- a/lib/mix/ex_oauth2_provider/migration.ex +++ b/lib/mix/ex_oauth2_provider/migration.ex @@ -10,11 +10,13 @@ defmodule Mix.ExOauth2Provider.Migration do @spec create_migration_file(atom(), binary(), binary()) :: any() def create_migration_file(repo, name, content) do base_name = "#{Macro.underscore(name)}.exs" - path = + + path = repo |> Mix.EctoSQL.source_repo_priv() |> Path.join("migrations") |> maybe_create_directory() + timestamp = timestamp(path) path @@ -34,8 +36,13 @@ defmodule Mix.ExOauth2Provider.Migration do |> Path.join("*_#{base_name}") |> Path.wildcard() |> case do - [] -> path - _ -> Mix.raise("migration can't be created, there is already a migration file with name #{name}.") + [] -> + path + + _ -> + Mix.raise( + "migration can't be created, there is already a migration file with name #{name}." + ) end end @@ -47,7 +54,7 @@ defmodule Mix.ExOauth2Provider.Migration do |> Path.wildcard() |> case do [] -> timestamp - _ -> timestamp(path, seconds + 1) + _ -> timestamp(path, seconds + 1) end end @@ -61,7 +68,7 @@ defmodule Mix.ExOauth2Provider.Migration do "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" end - defp pad(i) when i < 10, do: << ?0, ?0 + i >> + defp pad(i) when i < 10, do: <> defp pad(i), do: to_string(i) @template """ @@ -84,28 +91,49 @@ defmodule Mix.ExOauth2Provider.Migration do end """ - alias ExOauth2Provider.{AccessGrants.AccessGrant, AccessTokens.AccessToken, Applications.Application} + alias ExOauth2Provider.{ + AccessGrants.AccessGrant, + AccessTokens.AccessToken, + Applications.Application, + DeviceGrants.DeviceGrant + } - @schemas [{"applications", Application}, {"access_grants", AccessGrant}, {"access_tokens", AccessToken}] + @required_schemas [ + {"applications", Application}, + {"access_grants", AccessGrant}, + {"access_tokens", AccessToken} + ] + @optional_schemas %{device_grants: {"device_grants", DeviceGrant}} @spec gen(binary(), binary(), map()) :: binary() def gen(name, namespace, %{repo: repo} = config) do + schemas = @required_schemas ++ with_device_grants(config) + schemas = - for {table, module} <- @schemas, - do: schema(module, table, namespace, config) + for {table, module} <- schemas, + do: schema(module, table, namespace, config) EEx.eval_string(@template, migration: %{repo: repo, name: name, schemas: schemas}) end + defp with_device_grants(%{device_code: device_code} = _config) when device_code == true do + [Map.get(@optional_schemas, :device_grants)] + end + + defp with_device_grants(_config) do + [] + end + defp schema(module, table, namespace, %{binary_id: binary_id}) do - attrs = + attrs = module.attrs() |> Kernel.++(attrs_from_assocs(module.assocs(), namespace)) |> migration_attrs() - defaults = defaults(attrs) + + defaults = defaults(attrs) {assocs, attrs} = partition_attrs(attrs) - table = "#{namespace}_#{table}" - indexes = migration_indexes(module.indexes(), table) + table = "#{namespace}_#{table}" + indexes = migration_indexes(module.indexes(), table) %{ table: table, @@ -126,10 +154,14 @@ defmodule Mix.ExOauth2Provider.Migration do defp attr_from_assoc({:belongs_to, name, :users}, _namespace) do {String.to_atom("#{name}_id"), {:references, :users}} end + defp attr_from_assoc({:belongs_to, name, table}, namespace) do {String.to_atom("#{name}_id"), {:references, String.to_atom("#{namespace}_#{table}")}} end - defp attr_from_assoc({:belongs_to, name, table, _defaults}, namespace), do: attr_from_assoc({:belongs_to, name, table}, namespace) + + defp attr_from_assoc({:belongs_to, name, table, _defaults}, namespace), + do: attr_from_assoc({:belongs_to, name, table}, namespace) + defp attr_from_assoc(_assoc, _opts), do: nil defp migration_attrs(attrs) do @@ -139,11 +171,13 @@ defmodule Mix.ExOauth2Provider.Migration do defp to_migration_attr({name, type}) do {name, type, ""} end + defp to_migration_attr({name, type, []}) do to_migration_attr({name, type}) end + defp to_migration_attr({name, type, defaults}) do - defaults = Enum.map_join(defaults, ", ", fn {k, v} -> "#{k}: #{inspect v}" end) + defaults = Enum.map_join(defaults, ", ", fn {k, v} -> "#{k}: #{inspect(v)}" end) {name, type, ", #{defaults}"} end @@ -161,7 +195,8 @@ defmodule Mix.ExOauth2Provider.Migration do _ -> false end) - attrs = Enum.map(attrs, fn {key_id, type, _defaults} -> {key_id, type} end) + attrs = Enum.map(attrs, fn {key_id, type, _defaults} -> {key_id, type} end) + assocs = Enum.map(assocs, fn {key_id, {:references, source}, _} -> key = String.replace(Atom.to_string(key_id), "_id", "") diff --git a/lib/mix/ex_oauth2_provider/schema.ex b/lib/mix/ex_oauth2_provider/schema.ex index ceced4d6..7f722100 100644 --- a/lib/mix/ex_oauth2_provider/schema.ex +++ b/lib/mix/ex_oauth2_provider/schema.ex @@ -19,24 +19,57 @@ defmodule Mix.ExOauth2Provider.Schema do end """ - alias ExOauth2Provider.{AccessGrants.AccessGrant, AccessTokens.AccessToken, Applications.Application} + alias ExOauth2Provider.{ + AccessGrants.AccessGrant, + AccessTokens.AccessToken, + Applications.Application, + DeviceGrants.DeviceGrant + } - @schemas [{"application", Application}, {"access_grant", AccessGrant}, {"access_token", AccessToken}] + @required_schemas [ + {"application", Application}, + {"access_grant", AccessGrant}, + {"access_token", AccessToken} + ] + @optional_schemas [ + {"device_grant", DeviceGrant} + ] @spec create_schema_files(atom(), binary(), keyword()) :: any() def create_schema_files(context_app, namespace, opts) do - for {table, schema} <- @schemas do - app_base = Config.app_base(context_app) - table_name = "#{namespace}_#{table}s" - context = Macro.camelize(table_name) - module = Macro.camelize("#{namespace}_#{table}") - file = "#{Macro.underscore(module)}.ex" - module = Module.concat([app_base, context, module]) - binary_id = Keyword.get(opts, :binary_id, false) - macro = schema + app_base = Config.app_base(context_app) + binary_id = Keyword.get(opts, :binary_id, false) + use_device_code_flow = Keyword.get(opts, :device_code, false) + + schemas = + @required_schemas + |> Enum.concat(@optional_schemas) + |> Enum.filter(fn schema_def -> + Enum.member?(@required_schemas, schema_def) || use_device_code_flow + end) + + for {table, schema} <- schemas do + table_name = "#{namespace}_#{table}s" + context = Macro.camelize(table_name) + module = Macro.camelize("#{namespace}_#{table}") + file = "#{Macro.underscore(module)}.ex" + module = Module.concat([app_base, context, module]) + macro = schema macro_fields = "#{table}_fields" - content = EEx.eval_string(@template, schema: %{module: module, table: table_name, binary_id: binary_id, macro: macro, macro_fields: macro_fields}, otp_app: context_app) - dir = "lib/#{context_app}/#{Macro.underscore(context)}/" + + content = + EEx.eval_string(@template, + schema: %{ + module: module, + table: table_name, + binary_id: binary_id, + macro: macro, + macro_fields: macro_fields + }, + otp_app: context_app + ) + + dir = "lib/#{context_app}/#{Macro.underscore(context)}/" File.mkdir_p!(dir) diff --git a/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex b/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex index c7658eec..752b1a01 100644 --- a/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex +++ b/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex @@ -22,15 +22,16 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.Migration do * `-r`, `--repo` - the repo module * `--binary-id` - use binary id for primary keys + * `--device-code - create the tables needed for the device code flow * `--namespace` - namespace to prepend table and schema module name """ use Mix.Task alias Mix.{Ecto, ExOauth2Provider, ExOauth2Provider.Migration} - @switches [binary_id: :boolean, namespace: :string] - @default_opts [binary_id: false, namespace: "oauth"] - @mix_task "ex_oauth2_provider.gen.migrations" + @switches [binary_id: :boolean, device_code: :boolean, namespace: :string] + @default_opts [binary_id: false, device_code: false, namespace: "oauth"] + @mix_task "ex_oauth2_provider.gen.migrations" @impl true def run(args) do @@ -53,8 +54,8 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.Migration do end defp create_migration_files(%{repo: repo, namespace: namespace} = config) do - name = "Create#{Macro.camelize(namespace)}Tables" - content = Migration.gen(name, namespace, config) + name = "Create#{Macro.camelize(namespace)}Tables" + content = Migration.gen(name, namespace, config) Migration.create_migration_file(repo, name, content) end diff --git a/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex b/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex index 4f6b263c..3de7f006 100644 --- a/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex +++ b/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex @@ -11,16 +11,22 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.Schemas do ## Arguments * `--binary-id` - use binary id for primary keys - * `--namespace` - namespace to prepend table and schema module name * `--context-app` - context app to use for path and module names + * `--device-code` - generate an optional schema for device code grants + * `--namespace` - namespace to prepend table and schema module name """ use Mix.Task alias Mix.{ExOauth2Provider, ExOauth2Provider.Schema} - @switches [binary_id: :boolean, context_app: :string, namespace: :string] - @default_opts [binary_id: false, namespace: "oauth"] - @mix_task "ex_oauth2_provider.gen.migrations" + @switches [ + binary_id: :boolean, + context_app: :string, + device_code: :boolean, + namespace: :string + ] + @default_opts [binary_id: false, device_code: false, namespace: "oauth"] + @mix_task "ex_oauth2_provider.gen.migrations" @impl true def run(args) do @@ -34,9 +40,21 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.Schemas do defp parse({config, _parsed, _invalid}), do: config - defp create_schema_files(%{binary_id: binary_id, namespace: namespace} = config) do - context_app = Map.get(config, :context_app) || ExOauth2Provider.otp_app() - - Schema.create_schema_files(context_app, namespace, binary_id: binary_id) + defp create_schema_files( + %{ + binary_id: binary_id, + context_app: context_app, + device_code: device_code, + namespace: namespace + } = config + ) do + context_app = context_app || ExOauth2Provider.otp_app() + + Schema.create_schema_files( + context_app, + namespace, + binary_id: binary_id, + device_code: device_code + ) end end diff --git a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs new file mode 100644 index 00000000..246740ff --- /dev/null +++ b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs @@ -0,0 +1,171 @@ +defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorizationTest do + use ExOauth2Provider.TestCase + + alias Dummy.OauthDeviceGrants.OauthDeviceGrant + alias ExOauth2Provider.Config + alias ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization + alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} + + @config [otp_app: :ex_oauth2_provider] + + setup do + application = + Fixtures.application( + uid: "abc123", + scopes: "app:read app:write" + ) + + {:ok, %{application: application}} + end + + describe "#process_request/2" do + test "returns :ok tuple when request is valid", %{application: application} do + request = %{ + "client_id" => application.uid, + "response_type" => "device_code" + } + + {:ok, + %{ + device_code: device_code, + expires_in: expires_in, + interval: interval, + user_code: user_code, + verification_uri: verification_uri + }} = DeviceAuthorization.process_request(request, @config) + + assert device_code =~ ~r/[a-z0-9=_-]{44}/i + assert expires_in == Config.authorization_code_expires_in(@config) + assert interval == Config.device_flow_polling_interval(@config) + assert user_code =~ ~r/[A-Z0-9]{8}/ + refute verification_uri === nil + + device_grant = QueryHelpers.get_latest_inserted(OauthDeviceGrant) + + assert device_code == device_grant.device_code + assert expires_in == device_grant.expires_in + assert user_code == device_grant.user_code + assert device_grant.scopes == "" + assert device_grant.application_id == application.id + assert device_grant.resource_owner_id == nil + end + + test "returns :error tuple when client_id is not given" do + request = %{"response_type" => "device_code"} + + { + :error, + %{ + error: :invalid_request, + error_description: message + }, + :bad_request + } = DeviceAuthorization.process_request(request, @config) + + assert message =~ ~r/missing required param client_id/i + end + + test "returns :error tuple when client_id is not found" do + request = %{ + "client_id" => "this-is-non-existent", + "response_type" => "device_code" + } + + { + :error, + %{ + error: :invalid_client, + error_description: message + }, + :unauthorized + } = DeviceAuthorization.process_request(request, @config) + + assert message =~ ~r/unknown client/i + end + + test "accepts optional scopes when given", %{application: application} do + request = %{ + "client_id" => application.uid, + "response_type" => "device_code", + "scope" => "app:read" + } + + {:ok, _payload} = DeviceAuthorization.process_request(request, @config) + device_grant = QueryHelpers.get_latest_inserted(OauthDeviceGrant) + + assert device_grant.scopes == "app:read" + end + + test "returns :error tuple when scope is invalid", %{application: application} do + request = %{ + "client_id" => application.uid, + "response_type" => "device_code", + "scope" => "app:read app:write BAD:THING" + } + + { + :error, + %{ + error: :invalid_scope, + error_description: message + }, + :bad_request + } = DeviceAuthorization.process_request(request, @config) + + assert message =~ ~r/scope is invalid/i + end + + test "deletes expired grants during successful grant creation", %{application: application} do + device_grant = Fixtures.device_grant() + + inserted_at = + QueryHelpers.timestamp( + OauthDeviceGrant, + :inserted_at, + seconds: -device_grant.expires_in + ) + + QueryHelpers.change!(device_grant, inserted_at: inserted_at) + + request = %{ + "client_id" => application.uid, + "response_type" => "device_code" + } + + {:ok, _payload} = DeviceAuthorization.process_request(request, @config) + latest_device_grant = QueryHelpers.get_latest_inserted(OauthDeviceGrant) + + assert device_grant.id != latest_device_grant.id + assert QueryHelpers.count(OauthDeviceGrant) == 1 + end + end + + describe "#process_request/2 when application has no scopes" do + setup %{application: application} do + application = QueryHelpers.change!(application, scopes: "") + + %{application: application} + end + + test "it allows generic scopes", %{application: application} do + request = %{ + "client_id" => application.uid, + "response_type" => "device_code", + "scope" => "read" + } + + {:ok, _payload} = DeviceAuthorization.process_request(request, @config) + end + + test "it denies granular scopes", %{application: application} do + request = %{ + "client_id" => application.uid, + "response_type" => "device_code", + "scope" => "app:read" + } + + {:error, %{error: :invalid_scope}, :bad_request} = + DeviceAuthorization.process_request(request, @config) + end + end +end diff --git a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction_test.exs b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction_test.exs new file mode 100644 index 00000000..bd6d2245 --- /dev/null +++ b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction_test.exs @@ -0,0 +1,139 @@ +defmodule ExOauth2Provider.Authorization.DeviceCode.UserInteractionTest do + use ExOauth2Provider.TestCase + + alias Dummy.OauthDeviceGrants.OauthDeviceGrant + alias ExOauth2Provider.Authorization.DeviceCode.UserInteraction + alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} + + @config [otp_app: :ex_oauth2_provider] + + setup do + application = + Fixtures.application( + uid: "abc123", + scopes: "app:read app:write" + ) + + device_grant = Fixtures.device_grant(application_id: application.id) + owner = Fixtures.resource_owner() + + { + :ok, + %{ + application: application, + device_grant: device_grant, + owner: owner + } + } + end + + describe "#authorize/3" do + test "updates the grant and responds with :ok tuple with the grant", context do + %{device_grant: device_grant, owner: owner} = context + + request = %{ + "response_type" => "device_code", + "user_code" => device_grant.user_code + } + + {:ok, updated_grant} = UserInteraction.process_request(owner, request, @config) + + assert updated_grant.id == device_grant.id + assert updated_grant.user_code == nil + assert updated_grant.resource_owner_id == owner.id + end + + test "returns invalid_user_code when user_code is missing", context do + %{owner: owner} = context + + request = %{"response_type" => "device_code"} + + { + :error, + %{ + error: error_code, + error_description: message + }, + status_code + } = UserInteraction.process_request(owner, request, @config) + + assert error_code == :user_code_missing + assert status_code == :bad_request + assert message =~ ~r/missing the required param user_code/i + end + + test "returns invalid_user_code when no grant is found", %{owner: owner} do + request = %{ + "response_type" => "device_code", + "user_code" => "non-existent-code" + } + + { + :error, + %{ + error: error_code, + error_description: message + }, + status_code + } = UserInteraction.process_request(owner, request, @config) + + assert error_code == :invalid_user_code + assert status_code == :unprocessable_entity + assert message =~ ~r/code is invalid/i + end + + test "returns expired_user_code when the grant is expired", context do + %{device_grant: device_grant, owner: owner} = context + + inserted_at = + QueryHelpers.timestamp( + OauthDeviceGrant, + :inserted_at, + seconds: -device_grant.expires_in + ) + + QueryHelpers.change!(device_grant, inserted_at: inserted_at) + + request = %{ + "response_type" => "device_code", + "user_code" => device_grant.user_code + } + + { + :error, + %{ + error: error_code, + error_description: message + }, + status_code + } = UserInteraction.process_request(owner, request, @config) + + assert error_code == :expired_user_code + assert status_code == :unprocessable_entity + assert message =~ ~r/user_code has expired/i + end + + test "returns invalid_request when the DB update fails unexpectedly", context do + %{device_grant: device_grant} = context + non_existent_owner = %OauthDeviceGrant{id: "blah"} + + request = %{ + "response_type" => "device_code", + "user_code" => device_grant.user_code + } + + { + :error, + %{ + error: error_code, + error_description: message + }, + status_code + } = UserInteraction.process_request(non_existent_owner, request, @config) + + assert error_code == :invalid_owner + assert status_code == :unprocessable_entity + assert message =~ ~r/owner is invalid/i + end + end +end diff --git a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs new file mode 100644 index 00000000..1a72078a --- /dev/null +++ b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs @@ -0,0 +1,54 @@ +defmodule ExOauth2Provider.Authorization.DeviceCodeTest do + use ExOauth2Provider.TestCase + + alias Dummy.OauthDeviceGrants.OauthDeviceGrant + alias ExOauth2Provider.Authorization + alias ExOauth2Provider.Test.Fixtures + + @config [otp_app: :ex_oauth2_provider] + + setup do + application = + Fixtures.application( + uid: "abc123", + scopes: "app:read app:write" + ) + + {:ok, %{application: application}} + end + + describe "#authorize/3" do + test "invokes the user interaction and approves the device grant", context do + %{application: application} = context + device_grant = Fixtures.device_grant(application_id: application.id) + owner = Fixtures.resource_owner() + + request = %{ + "response_type" => "device_code", + "user_code" => device_grant.user_code + } + + {:ok, %OauthDeviceGrant{}} = Authorization.authorize(owner, request, @config) + end + end + + describe "#preauthorize/3" do + test "invokes the device authorization and creats the device grant", context do + %{application: application} = context + + request = %{ + "client_id" => application.uid, + "response_type" => "device_code" + } + + {:ok, + %{ + device_code: _device_code, + expires_in: _expires_in, + interval: _interval, + user_code: _user_code, + verification_uri: _verification_uri + }} = Authorization.preauthorize(nil, request, @config) + end + end +end diff --git a/test/ex_oauth2_provider/oauth2/token/strategy/device_code_test.exs b/test/ex_oauth2_provider/oauth2/token/strategy/device_code_test.exs new file mode 100644 index 00000000..2f0692b7 --- /dev/null +++ b/test/ex_oauth2_provider/oauth2/token/strategy/device_code_test.exs @@ -0,0 +1,501 @@ +defmodule ExOauth2Provider.Token.DeviceCodeTest do + use ExOauth2Provider.TestCase + + alias Dummy.OauthDeviceGrants.OauthDeviceGrant + alias Dummy.OauthAccessTokens.OauthAccessToken + alias ExOauth2Provider.{Config, Token} + alias ExOauth2Provider.AccessTokens + alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} + + @config [otp_app: :ex_oauth2_provider] + + setup do + application = + Fixtures.application( + uid: "abc123", + scopes: "app:read app:write", + secret: "" + ) + + owner = Fixtures.resource_owner() + + grant = + Fixtures.device_grant( + application_id: application.id, + resource_owner_id: owner.id, + user_code: nil + ) + + { + :ok, + %{ + application: application, + grant: grant, + owner: owner, + polling_interval: Config.device_flow_polling_interval(@config) + } + } + end + + describe "#grant/2 when request is valid" do + test "it returns the access token and deletes the grant", context do + %{application: application, grant: grant, owner: owner} = context + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :ok, + %{ + access_token: access_token, + expires: expires, + refresh_token: refresh_token, + scope: scope, + token_type: token_type + } + } = Token.grant(request, @config) + + assert access_token =~ ~r/[a-z0-9]{32,}/i + assert expires == Config.access_token_expires_in(@config) + assert refresh_token =~ ~r/[a-z0-9]{32,}/i + assert scope == "" + assert token_type == "bearer" + assert QueryHelpers.count(OauthDeviceGrant) == 0 + + record = QueryHelpers.get_latest_inserted(OauthAccessToken) + + assert record.application_id == application.id + assert record.expires_in == expires + assert record.previous_refresh_token == "" + assert record.refresh_token == refresh_token + assert record.resource_owner_id == owner.id + assert record.revoked_at == nil + assert record.scopes == scope + assert record.token == access_token + end + end + + describe "#grant/2 when device is polling too frequently" do + test "it returns the slow_down error and updates the last polled timestamp", context do + %{ + application: application, + grant: grant, + polling_interval: polling_interval + } = context + + last_polled_at = + QueryHelpers.timestamp( + OauthDeviceGrant, + :last_polled_at, + seconds: -polling_interval + ) + + QueryHelpers.change!(grant, last_polled_at: last_polled_at) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{error: :slow_down}, + :bad_request + } = Token.grant(request, @config) + + grant = QueryHelpers.get_latest_inserted(OauthDeviceGrant) + + assert grant.last_polled_at > last_polled_at + end + end + + describe "#grant/2 when previous requests were received and polling rate is OK" do + test "it does not block the request and behaves normally", context do + %{ + application: application, + grant: grant, + polling_interval: polling_interval + } = context + + last_polled_at = + QueryHelpers.timestamp( + OauthDeviceGrant, + :last_polled_at, + seconds: -(polling_interval + 1) + ) + + QueryHelpers.change!(grant, last_polled_at: last_polled_at) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, _payload} = Token.grant(request, @config) + end + end + + describe "#grant/2 when the grant is not approved yet but still valid" do + test "it returns the authorization_pending error and updates the last polled timestamp", + context do + %{application: application, grant: grant} = context + original_last_polled_at = grant.last_polled_at + + QueryHelpers.change!( + grant, + resource_owner_id: nil, + user_code: "still-waiting" + ) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{error: :authorization_pending}, + :bad_request + } = Token.grant(request, @config) + + grant = QueryHelpers.get_latest_inserted(OauthDeviceGrant) + + assert grant.last_polled_at > original_last_polled_at + end + end + + describe "#grant/2 when the device code has expired" do + test "it returns the expired_token error and destroys the grant", context do + %{application: application, grant: grant} = context + + inserted_at = + QueryHelpers.timestamp( + OauthDeviceGrant, + :inserted_at, + seconds: -Config.access_token_expires_in(@config) + ) + + QueryHelpers.change!(grant, inserted_at: inserted_at) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{error: :expired_token}, + :bad_request + } = Token.grant(request, @config) + + assert QueryHelpers.count(OauthDeviceGrant) == 0 + end + end + + describe "#grant/2 when device code is invalid" do + test "it returns the invalid_grant error", %{application: application} do + request = %{ + "client_id" => application.uid, + "device_code" => "this-wont-match", + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{ + error: :invalid_grant, + error_description: message + }, + :bad_request + } = Token.grant(request, @config) + + assert message =~ ~r/grant is invalid/i + end + end + + describe "#grant/2 when device code is missing" do + test "it returns the invalid_request error", %{application: application} do + request = %{ + "client_id" => application.uid, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{ + error: :invalid_request, + error_description: message + }, + :bad_request + } = Token.grant(request, @config) + + assert message =~ ~r/missing required param device_code/i + end + end + + describe "#grant/2 when client_id is invalid" do + test "it returns the invalid_client error", %{grant: grant} do + request = %{ + "client_id" => "this-is-not-matching", + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{ + error: :invalid_client, + error_description: message + }, + :unauthorized + } = Token.grant(request, @config) + + assert message =~ ~r/unknown client/i + end + end + + describe "#grant/2 when client_id is missing" do + test "it returns the invalid_request error" do + request = %{ + "device_code" => "abc123", + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{ + error: :invalid_request, + error_description: message + }, + :bad_request + } = Token.grant(request, @config) + + assert message =~ ~r/missing required param client_id/i + end + end + + describe "#grant/2 when client_id and device_code is missing" do + test "it returns the invalid_request error" do + request = %{"grant_type" => "urn:ietf:params:oauth:grant-type:device_code"} + + { + :error, + %{ + error: :invalid_request, + error_description: message + }, + :bad_request + } = Token.grant(request, @config) + + assert message =~ ~r/missing required param client_id, device_code/i + end + end + + describe "#grant/2 when application has no scopes" do + test "it creates the token with the configured default scope", context do + %{application: application, grant: grant} = context + + QueryHelpers.change!(application, scopes: "") + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, _payload} = Token.grant(request, @config) + + default_scopes = + @config + |> Config.default_scopes() + |> Enum.join(",") + + access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) + + assert access_token.scopes == default_scopes + end + end + + describe "#grant/2 when client_secret is given and valid" do + test "it behaves like normal", %{application: application, grant: grant} do + application = QueryHelpers.change!(application, secret: "secret") + + request = %{ + "client_id" => application.uid, + "client_secret" => application.secret, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, _payload} = Token.grant(request, @config) + end + end + + describe "#grant/2 when client_secret is given and invalid" do + test "it returns invalid_client error", context do + %{application: application, grant: grant} = context + + request = %{ + "client_id" => application.uid, + "client_secret" => "invalid-secret", + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + { + :error, + %{error: :invalid_client}, + :unauthorized + } = Token.grant(request, @config) + end + end + + describe "#grant/2 when valid scopes are given" do + test "it adds them to the token", %{application: application, grant: grant} do + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code", + "scope" => "app:read" + } + + {:ok, _payload} = Token.grant(request, @config) + access_token = QueryHelpers.get_latest_inserted(OauthAccessToken) + + assert access_token.scopes == "app:read" + end + end + + describe "#grant/2 when invalid scopes are given" do + test "it returns invalid_scope error", context do + %{application: application, grant: grant} = context + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code", + "scope" => "ability_to_delete_all_the_things!" + } + + { + :error, + %{ + error: :invalid_scope, + error_description: message + }, + :bad_request + } = Token.grant(request, @config) + + assert message =~ ~r/scope is invalid/i + end + end + + describe "#grant/2 when configured to not use refresh token" do + test "it does not set the refresh token", context do + %{application: application, grant: grant} = context + modified_config = Keyword.put(@config, :use_refresh_token, false) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, payload} = Token.grant(request, modified_config) + + record = QueryHelpers.get_latest_inserted(OauthAccessToken) + + assert record.refresh_token == nil + refute record.token == nil + assert record.refresh_token == payload.refresh_token + assert record.token == payload.access_token + end + end + + describe "#grant/2 when a valid access token already exists" do + test "it returns the existing token and deletes the grant", context do + %{application: application, grant: grant, owner: owner} = context + + existing_token = + Fixtures.access_token( + application: application, + resource_owner: owner, + scopes: "" + ) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, payload} = Token.grant(request, @config) + + assert existing_token.token == payload.access_token + assert QueryHelpers.count(OauthAccessToken) == 1 + assert QueryHelpers.count(OauthDeviceGrant) == 0 + end + end + + describe "#grant/2 when a revoked access token already exists" do + test "it creates a new one", context do + %{application: application, grant: grant, owner: owner} = context + + revoked_token = + [application: application, resource_owner: owner, scopes: ""] + |> Fixtures.access_token() + |> AccessTokens.revoke!() + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, payload} = Token.grant(request, @config) + + refute revoked_token.token == payload.access_token + assert QueryHelpers.count(OauthAccessToken) == 2 + end + end + + describe "#grant/2 when an expired access token already exists" do + test "it creates a new one", context do + %{application: application, grant: grant, owner: owner} = context + + expired_token = + Fixtures.access_token( + application: application, + resource_owner: owner, + scopes: "" + ) + + inserted_at = + QueryHelpers.timestamp( + OauthAccessToken, + :inserted_at, + seconds: -Config.access_token_expires_in(@config) + ) + + QueryHelpers.change!(expired_token, inserted_at: inserted_at) + + request = %{ + "client_id" => application.uid, + "device_code" => grant.device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + } + + {:ok, payload} = Token.grant(request, @config) + + refute expired_token.token == payload.access_token + assert QueryHelpers.count(OauthAccessToken) == 2 + end + end +end diff --git a/test/ex_oauth2_provider/oauth2/utils/device_flow_test.exs b/test/ex_oauth2_provider/oauth2/utils/device_flow_test.exs new file mode 100644 index 00000000..737102f3 --- /dev/null +++ b/test/ex_oauth2_provider/oauth2/utils/device_flow_test.exs @@ -0,0 +1,85 @@ +defmodule ExOauth2Provider.Utils.DeviceFlowTest do + use ExOauth2Provider.TestCase + + alias ExOauth2Provider.Utils.DeviceFlow + + describe "#generate_device_code/1" do + test "returns a unique 32 char string that's base64 encoded and url safe" do + expected_length = + "01234567890123456789012345678912" + |> Base.url_encode64() + |> String.length() + + codes = Enum.map(1..10, fn _n -> DeviceFlow.generate_device_code() end) + + num_uniq_codes = + codes + |> Enum.uniq() + |> Enum.count() + + assert num_uniq_codes == 10 + + assert Enum.all?( + codes, + fn code -> + assert code =~ ~r/[a-z0-9=_-]{#{expected_length}}/i + end + ) + end + + test "uses the length in the config when defined" do + code = + DeviceFlow.generate_device_code( + otp_app: :ex_oauth2_provider, + device_flow_device_code_length: 10 + ) + + expected_length = + "0123456789" + |> Base.url_encode64() + |> String.length() + + assert String.length(code) == expected_length + end + end + + describe "#generate_user_code/1" do + test "returns a unique 8 char alpha-numeric string" do + codes = Enum.map(1..10, fn _n -> DeviceFlow.generate_user_code() end) + + num_uniq_codes = + codes + |> Enum.uniq() + |> Enum.count() + + assert num_uniq_codes == 10 + + assert Enum.all?( + codes, + fn code -> + assert code =~ ~r/[a-z0-9]{8}/i + end + ) + end + + test "returns a code the same length as device_flow_device_code_length when given" do + code = + DeviceFlow.generate_user_code( + otp_app: :ex_oauth2_provider, + device_flow_user_code_length: 4 + ) + + assert String.length(code) == 4 + end + + test "returns a code using the value of device_flow_user_code_base when given" do + code = + DeviceFlow.generate_user_code( + otp_app: :ex_oauth2_provider, + device_flow_user_code_base: 2 + ) + + assert code =~ ~r/[01]{8}/ + end + end +end diff --git a/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs b/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs index be281c84..a5af7d78 100644 --- a/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs +++ b/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs @@ -10,7 +10,7 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do @tmp_path Path.join(["tmp", inspect(Migration)]) @migrations_path Path.join(@tmp_path, "migrations") - @options ~w(-r #{inspect Repo}) + @options ~w(-r #{inspect(Repo)}) setup do File.rm_rf!(@tmp_path) @@ -27,18 +27,25 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do file = @migrations_path |> Path.join(migration_file) |> File.read!() - assert file =~ "defmodule #{inspect Repo}.Migrations.CreateOauthTables do" + assert file =~ "defmodule #{inspect(Repo)}.Migrations.CreateOauthTables do" assert file =~ "use Ecto.Migration" assert file =~ "def change do" assert file =~ "add :owner_id, references(:users, on_delete: :nothing)" assert file =~ "add :resource_owner_id, references(:users, on_delete: :nothing)" refute file =~ "add :owner_id, references(:users, on_delete: :nothing, type: :binary_id)" - refute file =~ "add :resource_owner_id, references(:users, on_delete: :nothing, type: :binary_id)" + + refute file =~ + "add :resource_owner_id, references(:users, on_delete: :nothing, type: :binary_id)" + refute file =~ ":oauth_applications, primary_key: false" refute file =~ ":oauth_access_grants, primary_key: false" refute file =~ ":oauth_access_tokens, primary_key: false" refute file =~ "add :id, :binary_id, primary_key: true" - refute file =~ "add :application_id, references(:oauth_applications, on_delete: :nothing, type: binary_id)" + + refute file =~ + "add :application_id, references(:oauth_applications, on_delete: :nothing, type: binary_id)" + + refute file =~ ":oauth_device_grants" end) end @@ -53,12 +60,52 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do refute file =~ "add :owner_id, :integer, null: false" refute file =~ "add :resource_owner_id, :integer" assert file =~ "add :owner_id, references(:users, on_delete: :nothing, type: :binary_id)" - assert file =~ "add :resource_owner_id, references(:users, on_delete: :nothing, type: :binary_id)" + + assert file =~ + "add :resource_owner_id, references(:users, on_delete: :nothing, type: :binary_id)" + assert file =~ ":oauth_applications, primary_key: false" assert file =~ ":oauth_access_grants, primary_key: false" assert file =~ ":oauth_access_tokens, primary_key: false" assert file =~ "add :id, :binary_id, primary_key: true" - assert file =~ "add :application_id, references(:oauth_applications, on_delete: :nothing, type: :binary_id)" + + assert file =~ + "add :application_id, references(:oauth_applications, on_delete: :nothing, type: :binary_id)" + end) + end + + test "it creates device_grants table when --device-code option is given" do + File.cd!(@tmp_path, fn -> + Migration.run(@options ++ ~w(--device-code)) + + assert [migration_file] = File.ls!(@migrations_path) + + file = @migrations_path |> Path.join(migration_file) |> File.read!() + + assert file =~ ":oauth_applications" + assert file =~ ":oauth_access_grants" + assert file =~ ":oauth_access_tokens" + + create_table_content = + [ + " create table(:oauth_device_grants) do", + " add :device_code, :string, null: false", + " add :expires_in, :integer, null: false", + " add :last_polled_at, :utc_datetime", + " add :scopes, :string", + " add :user_code, :string", + " add :application_id, references(:oauth_applications, on_delete: :nothing)", + " add :resource_owner_id, references(:users, on_delete: :nothing)", + "", + " timestamps()", + " end", + "", + " create unique_index(:oauth_device_grants, [:device_code])", + " create unique_index(:oauth_device_grants, [:user_code])" + ] + |> Enum.join("\n") + + assert file =~ create_table_content end) end @@ -66,9 +113,11 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do File.cd!(@tmp_path, fn -> Migration.run(@options) - assert_raise Mix.Error, "migration can't be created, there is already a migration file with name CreateOauthTables.", fn -> - Migration.run(@options) - end + assert_raise Mix.Error, + "migration can't be created, there is already a migration file with name CreateOauthTables.", + fn -> + Migration.run(@options) + end end) end end diff --git a/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs b/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs index d63bd4c6..5a8cf975 100644 --- a/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs +++ b/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs @@ -5,7 +5,8 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do @tmp_path Path.join(["tmp", inspect(Schemas)]) @options ~w(--context-app test) - @files ["access_grant", "access_token", "application"] + @required_files ~w(access_grant access_token application) + @optional_files ~w(device_grant) setup do File.rm_rf!(@tmp_path) @@ -20,17 +21,78 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do Schemas.run(@options) - for file <- @files do + for file <- @required_files do path = Path.join([root_path, "oauth_#{file}s", "oauth_#{file}.ex"]) assert File.exists?(path) - module = Module.concat(["Test", Macro.camelize("oauth_#{file}s"), Macro.camelize("oauth_#{file}")]) - macro = Module.concat(["ExOauth2Provider", Macro.camelize("#{file}s"), Macro.camelize("#{file}")]) + module = + Module.concat([ + "Test", + Macro.camelize("oauth_#{file}s"), + Macro.camelize("oauth_#{file}") + ]) + + macro = + Module.concat([ + "ExOauth2Provider", + Macro.camelize("#{file}s"), + Macro.camelize("#{file}") + ]) + + content = File.read!(path) + + assert content =~ "defmodule #{inspect(module)} do" + assert content =~ "use #{inspect(macro)}, otp_app: :test" + assert content =~ "schema \"oauth_#{file}s\" do" + assert content =~ "#{file}_fields()" + end + + for file <- @optional_files do + path = Path.join([root_path, "oauth_#{file}s", "oauth_#{file}.ex"]) + + refute File.exists?(path) + end + end) + end + + test "it creates the device_grant schema when config has device_code grant flow" do + File.cd!(@tmp_path, fn -> + root_path = Path.join(["lib", "test"]) + + @options + |> Enum.concat(~w(--device-code)) + |> Schemas.run() + + for file <- @required_files do + path = Path.join([root_path, "oauth_#{file}s", "oauth_#{file}.ex"]) + + assert File.exists?(path) + end + + for file <- @optional_files do + path = Path.join([root_path, "oauth_#{file}s", "oauth_#{file}.ex"]) + + assert File.exists?(path) + + module = + Module.concat([ + "Test", + Macro.camelize("oauth_#{file}s"), + Macro.camelize("oauth_#{file}") + ]) + + macro = + Module.concat([ + "ExOauth2Provider", + Macro.camelize("#{file}s"), + Macro.camelize("#{file}") + ]) + content = File.read!(path) - assert content =~ "defmodule #{inspect module} do" - assert content =~ "use #{inspect macro}, otp_app: :test" + assert content =~ "defmodule #{inspect(module)} do" + assert content =~ "use #{inspect(macro)}, otp_app: :test" assert content =~ "schema \"oauth_#{file}s\" do" assert content =~ "#{file}_fields()" end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 988b1593..63abf449 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -2,7 +2,15 @@ defmodule ExOauth2Provider.Test.Fixtures do @moduledoc false alias ExOauth2Provider.AccessTokens - alias Dummy.{OauthApplications.OauthApplication, OauthAccessGrants.OauthAccessGrant, Repo, Users.User} + + alias Dummy.{ + OauthApplications.OauthApplication, + OauthAccessGrants.OauthAccessGrant, + OauthDeviceGrants.OauthDeviceGrant, + Repo, + Users.User + } + alias Ecto.Changeset def resource_owner(attrs \\ []) do @@ -16,13 +24,16 @@ defmodule ExOauth2Provider.Test.Fixtures do def application(attrs \\ []) do resource_owner = Keyword.get(attrs, :resource_owner) || resource_owner() - attrs = [ - owner_id: resource_owner.id, - uid: "test", - secret: "secret", - name: "OAuth Application", - redirect_uri: "urn:ietf:wg:oauth:2.0:oob", - scopes: "public read write"] + + attrs = + [ + owner_id: resource_owner.id, + uid: "test", + secret: "secret", + name: "OAuth Application", + redirect_uri: "urn:ietf:wg:oauth:2.0:oob", + scopes: "public read write" + ] |> Keyword.merge(attrs) |> Keyword.drop([:resource_owner]) @@ -51,7 +62,6 @@ defmodule ExOauth2Provider.Test.Fixtures do access_token end - def access_grant(application, user, code, redirect_uri) do attrs = [ expires_in: 900, @@ -67,4 +77,18 @@ defmodule ExOauth2Provider.Test.Fixtures do |> Changeset.change(attrs) |> Repo.insert!() end + + def device_grant(attrs \\ []) do + attrs = + [ + device_code: "device-code", + expires_in: 900, + user_code: "user-code" + ] + |> Keyword.merge(attrs) + + %OauthDeviceGrant{} + |> Changeset.change(attrs) + |> Repo.insert!() + end end diff --git a/test/support/lib/dummy/oauth_device_grants/oauth_device_grant.ex b/test/support/lib/dummy/oauth_device_grants/oauth_device_grant.ex new file mode 100644 index 00000000..4a720faf --- /dev/null +++ b/test/support/lib/dummy/oauth_device_grants/oauth_device_grant.ex @@ -0,0 +1,16 @@ +defmodule Dummy.OauthDeviceGrants.OauthDeviceGrant do + @moduledoc false + + use Ecto.Schema + use ExOauth2Provider.DeviceGrants.DeviceGrant, otp_app: :ex_oauth2_provider + + if System.get_env("UUID") do + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + end + + schema "oauth_device_grants" do + device_grant_fields() + timestamps() + end +end diff --git a/test/support/priv/migrations/2_create_oauth_tables.exs b/test/support/priv/migrations/2_create_oauth_tables.exs index 45702729..149df877 100644 --- a/test/support/priv/migrations/2_create_oauth_tables.exs +++ b/test/support/priv/migrations/2_create_oauth_tables.exs @@ -1,6 +1,11 @@ require Mix.ExOauth2Provider.Migration binary_id = if System.get_env("UUID"), do: true, else: false + "CreateOauthTables" -|> Mix.ExOauth2Provider.Migration.gen("oauth", %{repo: ExOauth2Provider.Test.Repo, binary_id: binary_id}) +|> Mix.ExOauth2Provider.Migration.gen("oauth", %{ + repo: ExOauth2Provider.Test.Repo, + binary_id: binary_id, + device_code: true +}) |> Code.eval_string() diff --git a/test/support/query_helpers.ex b/test/support/query_helpers.ex index adae8c9f..decf6708 100644 --- a/test/support/query_helpers.ex +++ b/test/support/query_helpers.ex @@ -21,6 +21,10 @@ defmodule ExOauth2Provider.Test.QueryHelpers do defp convert_timestamp({key, %DateTime{} = value}), do: {key, %{value | microsecond: {0, 0}}} defp convert_timestamp(any), do: any + def count(schema) do + Repo.one(from(r in schema, select: count(r.id))) + end + def get_latest_inserted(module) do module |> order_by([x], desc: x.id) From ffd2b2c81b8da217a1106bf1d4e56d5c3af74218 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Wed, 25 Aug 2021 15:03:20 -0700 Subject: [PATCH 02/11] [PATCH] Fix schema generation when --context-app is not given and migration gen documentation --- .../device_grants/device_grant.ex | 2 +- .../device_grants/device_grants.ex | 2 - .../device_code/device_authorization.ex | 1 - lib/ex_oauth2_provider/oauth2/token/utils.ex | 4 +- .../tasks/ex_oauth2_provider.gen.migration.ex | 2 +- .../tasks/ex_oauth2_provider.gen.schemas.ex | 8 ++- mix.exs | 13 ++-- .../ex_oauth2_provider.gen.schemas_test.exs | 67 +++++++++++-------- 8 files changed, 56 insertions(+), 43 deletions(-) diff --git a/lib/ex_oauth2_provider/device_grants/device_grant.ex b/lib/ex_oauth2_provider/device_grants/device_grant.ex index e66a3c1d..9d94ff95 100644 --- a/lib/ex_oauth2_provider/device_grants/device_grant.ex +++ b/lib/ex_oauth2_provider/device_grants/device_grant.ex @@ -62,7 +62,7 @@ defmodule ExOauth2Provider.DeviceGrants.DeviceGrant do end alias Ecto.Changeset - alias ExOauth2Provider.{Mixin.Scopes, Utils} + alias ExOauth2Provider.Mixin.Scopes @spec changeset(Ecto.Schema.t(), map(), keyword()) :: Changeset.t() def changeset(grant, params, config) do diff --git a/lib/ex_oauth2_provider/device_grants/device_grants.ex b/lib/ex_oauth2_provider/device_grants/device_grants.ex index 43490a0d..442e2060 100644 --- a/lib/ex_oauth2_provider/device_grants/device_grants.ex +++ b/lib/ex_oauth2_provider/device_grants/device_grants.ex @@ -94,8 +94,6 @@ defmodule ExOauth2Provider.DeviceGrants do |> get_repo(config).update!() end - defp device_grant_schema(config), do: Config.device_grant(config) - defp fetch_grant_with_user_code({schema, repo}, user_code) do # from(d in schema, preload: [:application], where: d.user_code == ^user_code) schema diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex index 07209605..46bd7734 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex @@ -3,7 +3,6 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization do Config, DeviceGrants, Authorization.Utils, - Scopes, Utils.DeviceFlow, Utils.Error, Utils.Validation diff --git a/lib/ex_oauth2_provider/oauth2/token/utils.ex b/lib/ex_oauth2_provider/oauth2/token/utils.ex index bc3e9181..ca9bdbb7 100644 --- a/lib/ex_oauth2_provider/oauth2/token/utils.ex +++ b/lib/ex_oauth2_provider/oauth2/token/utils.ex @@ -5,10 +5,12 @@ defmodule ExOauth2Provider.Token.Utils do @doc false @spec load_client({:ok, map()}, keyword()) :: {:ok, map()} | {:error, map()} + def load_client(context, config, opts \\ []) + def load_client( {:ok, %{request: request = %{"client_id" => client_id}} = params}, config, - opts \\ [] + opts ) do client_secret = Map.get(request, "client_secret", "") diff --git a/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex b/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex index 752b1a01..ccd566da 100644 --- a/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex +++ b/lib/mix/tasks/ex_oauth2_provider.gen.migration.ex @@ -22,7 +22,7 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.Migration do * `-r`, `--repo` - the repo module * `--binary-id` - use binary id for primary keys - * `--device-code - create the tables needed for the device code flow + * `--device-code` - create the tables needed for the device code flow * `--namespace` - namespace to prepend table and schema module name """ use Mix.Task diff --git a/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex b/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex index 3de7f006..7f140b3f 100644 --- a/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex +++ b/lib/mix/tasks/ex_oauth2_provider.gen.schemas.ex @@ -43,12 +43,16 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.Schemas do defp create_schema_files( %{ binary_id: binary_id, - context_app: context_app, device_code: device_code, namespace: namespace } = config ) do - context_app = context_app || ExOauth2Provider.otp_app() + context_app = + Map.get( + config, + :context_app, + ExOauth2Provider.otp_app() + ) Schema.create_schema_files( context_app, diff --git a/mix.exs b/mix.exs index 675468c6..b22b19e8 100644 --- a/mix.exs +++ b/mix.exs @@ -8,8 +8,8 @@ defmodule ExOauth2Provider.Mixfile do app: :ex_oauth2_provider, version: @version, elixir: "~> 1.8", - elixirc_paths: elixirc_paths(Mix.env), - start_permanent: Mix.env == :prod, + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, deps: deps(), # Hex @@ -23,7 +23,7 @@ defmodule ExOauth2Provider.Mixfile do end def application do - [extra_applications: extra_applications(Mix.env)] + [extra_applications: extra_applications(Mix.env())] end defp extra_applications(:test), do: [:ecto, :logger] @@ -39,12 +39,11 @@ defmodule ExOauth2Provider.Mixfile do # Dev and test dependencies {:credo, "~> 1.1.0", only: [:dev, :test]}, - {:ex_doc, ">= 0.0.0", only: :dev}, - - {:ecto_sql, "~> 3.0.0", only: :test}, + {:ecto_sql, "~> 3.0.0", only: [:dev, :test]}, {:plug_cowboy, "~> 2.0", only: :test}, - {:postgrex, "~> 0.14", only: :test}] + {:postgrex, "~> 0.14", only: :test} + ] end defp package do diff --git a/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs b/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs index 5a8cf975..92f77003 100644 --- a/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs +++ b/test/mix/tasks/ex_oauth2_provider.gen.schemas_test.exs @@ -26,20 +26,8 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do assert File.exists?(path) - module = - Module.concat([ - "Test", - Macro.camelize("oauth_#{file}s"), - Macro.camelize("oauth_#{file}") - ]) - - macro = - Module.concat([ - "ExOauth2Provider", - Macro.camelize("#{file}s"), - Macro.camelize("#{file}") - ]) - + module = modulize(file) + macro = macroize(file) content = File.read!(path) assert content =~ "defmodule #{inspect(module)} do" @@ -56,6 +44,25 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do end) end + test "it uses the configured otp_app when --context-app is not given" do + File.cd!(@tmp_path, fn -> + # Test config defines :ex_oauth2_provider as the otp_app + root_path = Path.join(["lib", "ex_oauth2_provider"]) + + Schemas.run([]) + + for file <- @required_files do + path = Path.join([root_path, "oauth_#{file}s", "oauth_#{file}.ex"]) + + assert File.exists?(path) + + macro = macroize(file) + + assert File.read!(path) =~ "use #{inspect(macro)}, otp_app: :ex_oauth2_provider" + end + end) + end + test "it creates the device_grant schema when config has device_code grant flow" do File.cd!(@tmp_path, fn -> root_path = Path.join(["lib", "test"]) @@ -75,20 +82,8 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do assert File.exists?(path) - module = - Module.concat([ - "Test", - Macro.camelize("oauth_#{file}s"), - Macro.camelize("oauth_#{file}") - ]) - - macro = - Module.concat([ - "ExOauth2Provider", - Macro.camelize("#{file}s"), - Macro.camelize("#{file}") - ]) - + module = modulize(file) + macro = macroize(file) content = File.read!(path) assert content =~ "defmodule #{inspect(module)} do" @@ -98,4 +93,20 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.SchemasTest do end end) end + + defp modulize(file) do + Module.concat([ + "Test", + Macro.camelize("oauth_#{file}s"), + Macro.camelize("oauth_#{file}") + ]) + end + + defp macroize(file) do + Module.concat([ + "ExOauth2Provider", + Macro.camelize("#{file}s"), + Macro.camelize("#{file}") + ]) + end end From b6d86d35b01d3c62e2a3b9b7c104cbbffba28689 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Thu, 26 Aug 2021 11:34:18 -0700 Subject: [PATCH 03/11] [MINOR] Add preauthorize_device to simplify usage --- .../oauth2/authorization.ex | 13 +++ .../strategy/device_code_test.exs | 9 +- .../oauth2/authorization_test.exs | 104 ++++++++++++------ 3 files changed, 88 insertions(+), 38 deletions(-) diff --git a/lib/ex_oauth2_provider/oauth2/authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization.ex index 9d40ce93..c0852d6b 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization.ex @@ -29,6 +29,19 @@ defmodule ExOauth2Provider.Authorization do end end + @doc """ + Authorize access for a device. See #preauthorize/3 for more details. + """ + @spec preauthorize_device(map(), keyword()) :: + Response.success() | Response.error() | Response.redirect() | Response.native_redirect() + def preauthorize_device(request, config \\ []) do + # This wraps preauthorize but since there's no user or response type + # in these requests we can pass what's needed. + request = Map.put(request, "response_type", "device_code") + + preauthorize(nil, request, config) + end + @doc """ Check ExOauth2Provider.Authorization.Code for usage. """ diff --git a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs index 1a72078a..8ad9a074 100644 --- a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs +++ b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code_test.exs @@ -32,14 +32,11 @@ defmodule ExOauth2Provider.Authorization.DeviceCodeTest do end end - describe "#preauthorize/3" do + describe "#preauthorize_device/2" do test "invokes the device authorization and creats the device grant", context do %{application: application} = context - request = %{ - "client_id" => application.uid, - "response_type" => "device_code" - } + request = %{"client_id" => application.uid} {:ok, %{ @@ -48,7 +45,7 @@ defmodule ExOauth2Provider.Authorization.DeviceCodeTest do interval: _interval, user_code: _user_code, verification_uri: _verification_uri - }} = Authorization.preauthorize(nil, request, @config) + }} = Authorization.preauthorize_device(request, @config) end end end diff --git a/test/ex_oauth2_provider/oauth2/authorization_test.exs b/test/ex_oauth2_provider/oauth2/authorization_test.exs index e6074033..019cb954 100644 --- a/test/ex_oauth2_provider/oauth2/authorization_test.exs +++ b/test/ex_oauth2_provider/oauth2/authorization_test.exs @@ -4,75 +4,115 @@ defmodule ExOauth2Provider.AuthorizationTest do alias ExOauth2Provider.Authorization alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} - @client_id "Jf5rM8hQBc" - @client_secret "secret" - @valid_request %{"client_id" => @client_id, "response_type" => "code", "scope" => "app:read app:write"} - @invalid_request %{error: :invalid_request, - error_description: "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." - } - @invalid_response_type %{error: :unsupported_response_type, - error_description: "The authorization server does not support this response type." - } + @client_id "Jf5rM8hQBc" + @client_secret "secret" + @valid_request %{ + "client_id" => @client_id, + "response_type" => "code", + "scope" => "app:read app:write" + } + @invalid_request %{ + error: :invalid_request, + error_description: + "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." + } + @invalid_response_type %{ + error: :unsupported_response_type, + error_description: "The authorization server does not support this response type." + } setup do user = Fixtures.resource_owner() - application = Fixtures.application(resource_owner: user, uid: @client_id, secret: @client_secret) + + application = + Fixtures.application(resource_owner: user, uid: @client_id, secret: @client_secret) + {:ok, %{resource_owner: user, application: application}} end - test "#preauthorize/3 error when missing response_type", %{resource_owner: resource_owner} do - params = Map.delete(@valid_request, "response_type") + test "#preauthorize_device/2 does stuff and junk", %{application: application} do + response = + %{"client_id" => application.uid} + |> Authorization.preauthorize_device(otp_app: :ex_oauth2_provider) - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert {:ok, %{user_code: _user_code}} = response end - test "#preauthorize/3 redirect when missing response_type", %{resource_owner: resource_owner, application: application} do - QueryHelpers.change!(application, redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path") + test "#preauthorize/3 error when missing response_type", %{resource_owner: resource_owner} do + params = Map.delete(@valid_request, "response_type") - params = @valid_request - |> Map.delete("response_type") - |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) + assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_request, :bad_request} + end - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:redirect, "https://example.com/path?error=invalid_request&error_description=The+request+is+missing+a+required+parameter%2C+includes+an+unsupported+parameter+value%2C+or+is+otherwise+malformed.¶m=1&state=40612"} + test "#preauthorize/3 redirect when missing response_type", %{ + resource_owner: resource_owner, + application: application + } do + QueryHelpers.change!(application, + redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path" + ) + + params = + @valid_request + |> Map.delete("response_type") + |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) + + assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:redirect, + "https://example.com/path?error=invalid_request&error_description=The+request+is+missing+a+required+parameter%2C+includes+an+unsupported+parameter+value%2C+or+is+otherwise+malformed.¶m=1&state=40612"} end test "#preauthorize/3 error when unsupported response type", %{resource_owner: resource_owner} do params = Map.merge(@valid_request, %{"response_type" => "invalid"}) - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_response_type, :unprocessable_entity} + assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_response_type, :unprocessable_entity} end - test "#preauthorize/3 redirect when unsupported response_type", %{resource_owner: resource_owner, application: application} do - QueryHelpers.change!(application, redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path") - - params = @valid_request - |> Map.merge(%{"response_type" => "invalid"}) - |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) - - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:redirect, "https://example.com/path?error=unsupported_response_type&error_description=The+authorization+server+does+not+support+this+response+type.¶m=1&state=40612"} + test "#preauthorize/3 redirect when unsupported response_type", %{ + resource_owner: resource_owner, + application: application + } do + QueryHelpers.change!(application, + redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path" + ) + + params = + @valid_request + |> Map.merge(%{"response_type" => "invalid"}) + |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) + + assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:redirect, + "https://example.com/path?error=unsupported_response_type&error_description=The+authorization+server+does+not+support+this+response+type.¶m=1&state=40612"} end test "#authorize/3 error when missing response_type", %{resource_owner: resource_owner} do params = Map.delete(@valid_request, "response_type") - assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_request, :bad_request} end test "#authorize/3 rejects when unsupported response type", %{resource_owner: resource_owner} do params = Map.merge(@valid_request, %{"response_type" => "invalid"}) - assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_response_type, :unprocessable_entity} + assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_response_type, :unprocessable_entity} end test "#deny/3 error when missing response_type", %{resource_owner: resource_owner} do params = Map.delete(@valid_request, "response_type") - assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_request, :bad_request} end test "#deny/3 rejects when unsupported response type", %{resource_owner: resource_owner} do params = Map.merge(@valid_request, %{"response_type" => "invalid"}) - assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == {:error, @invalid_response_type, :unprocessable_entity} + assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_response_type, :unprocessable_entity} end end From 54fb6a697a52476e94dc99e210073591ee39a1b8 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Fri, 27 Aug 2021 11:01:02 -0700 Subject: [PATCH 04/11] [PATCH] Add dialyxer as a dev dependency - fix various typespec problems --- .gitignore | 1 + lib/ex_oauth2_provider/config.ex | 18 +++---- .../oauth2/authorization.ex | 5 +- .../device_code/device_authorization.ex | 1 + .../oauth2/authorization/utils.ex | 2 +- .../oauth2/authorization/utils/response.ex | 50 +++++++++++++------ .../oauth2/utils/device_flow.ex | 4 ++ mix.exs | 6 +++ mix.lock | 46 +++++++++-------- 9 files changed, 85 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index b44d958b..8c36eebe 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ erl_crash.dump .DS_Store /.elixir_ls *.swp +/plts .tool-versions diff --git a/lib/ex_oauth2_provider/config.ex b/lib/ex_oauth2_provider/config.ex index c7a01712..9d32ce50 100644 --- a/lib/ex_oauth2_provider/config.ex +++ b/lib/ex_oauth2_provider/config.ex @@ -149,23 +149,23 @@ defmodule ExOauth2Provider.Config do end end - @spec device_flow_device_code_length(keyword()) :: [integer()] + @spec device_flow_device_code_length(keyword()) :: non_neg_integer() def device_flow_device_code_length(config), - do: get(config, :device_flow_device_code_length, 32) + do: config |> get(:device_flow_device_code_length, 32) |> abs() - @spec device_flow_polling_interval(keyword()) :: [integer()] + @spec device_flow_polling_interval(keyword()) :: non_neg_integer() def device_flow_polling_interval(config), - do: get(config, :device_flow_polling_interval, 5) + do: config |> get(:device_flow_polling_interval, 5) |> abs() - @spec device_flow_user_code_base(keyword()) :: [integer()] + @spec device_flow_user_code_base(keyword()) :: non_neg_integer() def device_flow_user_code_base(config), - do: get(config, :device_flow_user_code_base, 36) + do: config |> get(:device_flow_user_code_base, 36) |> abs() - @spec device_flow_user_code_length(keyword()) :: [integer()] + @spec device_flow_user_code_length(keyword()) :: non_neg_integer() def device_flow_user_code_length(config), - do: get(config, :device_flow_user_code_length, 8) + do: config |> get(:device_flow_user_code_length, 8) |> abs() - @spec device_flow_verification_uri(keyword()) :: [binary()] + @spec device_flow_verification_uri(keyword()) :: binary() def device_flow_verification_uri(config), do: get(config, :device_flow_verification_uri) || diff --git a/lib/ex_oauth2_provider/oauth2/authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization.ex index c0852d6b..8138057f 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization.ex @@ -13,8 +13,11 @@ defmodule ExOauth2Provider.Authorization do @doc """ Check ExOauth2Provider.Authorization.Code for usage. + + NOTE: the first argument is allowed to be nil because device authorization + does not have a user, there is no session. """ - @spec preauthorize(Schema.t(), map(), keyword()) :: + @spec preauthorize(Schema.t() | nil, map(), keyword()) :: Response.success() | Response.error() | Response.redirect() | Response.native_redirect() def preauthorize(resource_owner, request, config \\ []) do case validate_response_type(request, config) do diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex index 46bd7734..631f9dd5 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex @@ -41,6 +41,7 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization do {:error, error, http_status} end + @spec generate_grant_params(map(), keyword()) :: map() defp generate_grant_params(request, config) do %{ device_code: DeviceFlow.generate_device_code(), diff --git a/lib/ex_oauth2_provider/oauth2/authorization/utils.ex b/lib/ex_oauth2_provider/oauth2/authorization/utils.ex index 61cec351..f7b14a6e 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/utils.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/utils.ex @@ -5,7 +5,7 @@ defmodule ExOauth2Provider.Authorization.Utils do alias Ecto.Schema @doc false - @spec prehandle_request(Schema.t(), map(), keyword()) :: {:ok, map()} | {:error, map()} + @spec prehandle_request(Schema.t() | nil, map(), keyword()) :: {:ok, map()} | {:error, map()} def prehandle_request(resource_owner, request, config, opts \\ []) do resource_owner |> new_params(request) diff --git a/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex b/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex index b6e6be3c..4361d2d9 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex @@ -2,38 +2,49 @@ defmodule ExOauth2Provider.Authorization.Utils.Response do @moduledoc false alias ExOauth2Provider.{RedirectURI, Scopes, Utils} - alias Ecto.Schema @type native_redirect :: {:native_redirect, %{code: binary()}} @type redirect :: {:redirect, binary()} @type error :: {:error, map(), integer()} - @type success :: {:ok, Schema.t(), [binary()]} + @type success :: {:ok, map()} @doc false @spec error_response({:error, map()}, keyword()) :: error() | redirect() | native_redirect() - def error_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) + def error_response({:error, %{error: error} = params}, config), + do: build_response(params, error, config) @doc false - @spec preauthorize_response({:ok, map()} | {:error, map()}, keyword()) :: success() | error() | redirect() | native_redirect() - def preauthorize_response({:ok, %{grant: grant} = params}, config), do: build_response(params, %{code: grant.token}, config) - def preauthorize_response({:ok, %{client: client, request: %{"scope" => scopes}}}, _config), do: {:ok, client, Scopes.to_list(scopes)} - def preauthorize_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) + @spec preauthorize_response({:ok, map()} | {:error, map()}, keyword()) :: + success() | error() | redirect() | native_redirect() + def preauthorize_response({:ok, %{grant: grant} = params}, config), + do: build_response(params, %{code: grant.token}, config) + + def preauthorize_response({:ok, %{client: client, request: %{"scope" => scopes}}}, _config), + do: {:ok, client, Scopes.to_list(scopes)} + + def preauthorize_response({:error, %{error: error} = params}, config), + do: build_response(params, error, config) @doc false - @spec authorize_response({:ok, map()} | {:error, map()}, keyword()) :: success() | error() | redirect() | native_redirect() - def authorize_response({:ok, %{grant: grant} = params}, config), do: build_response(params, %{code: grant.token}, config) - def authorize_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) + @spec authorize_response({:ok, map()} | {:error, map()}, keyword()) :: + success() | error() | redirect() | native_redirect() + def authorize_response({:ok, %{grant: grant} = params}, config), + do: build_response(params, %{code: grant.token}, config) + + def authorize_response({:error, %{error: error} = params}, config), + do: build_response(params, error, config) @doc false @spec deny_response({:error, map()}, keyword()) :: error() | redirect() | native_redirect() - def deny_response({:error, %{error: error} = params}, config), do: build_response(params, error, config) + def deny_response({:error, %{error: error} = params}, config), + do: build_response(params, error, config) defp build_response(%{request: request} = params, payload, config) do payload = add_state(payload, request) case can_redirect?(params, config) do true -> build_redirect_response(params, payload, config) - _ -> build_standard_response(params, payload) + _ -> build_standard_response(params, payload) end end @@ -52,23 +63,32 @@ defmodule ExOauth2Provider.Authorization.Utils.Response do defp build_redirect_response(%{request: %{"redirect_uri" => redirect_uri}}, payload, config) do case RedirectURI.native_redirect_uri?(redirect_uri, config) do true -> {:native_redirect, payload} - _ -> {:redirect, RedirectURI.uri_with_query(redirect_uri, payload)} + _ -> {:redirect, RedirectURI.uri_with_query(redirect_uri, payload)} end end defp build_standard_response(%{grant: _}, payload) do {:ok, payload} end + defp build_standard_response(%{error: error, error_http_status: error_http_status}, _) do {:error, error, error_http_status} end - defp build_standard_response(%{error: error}, _) do # For DB errors + + # For DB errors + defp build_standard_response(%{error: error}, _) do {:error, error, :bad_request} end defp can_redirect?(%{error: %{error: :invalid_redirect_uri}}, _config), do: false defp can_redirect?(%{error: %{error: :invalid_client}}, _config), do: false - defp can_redirect?(%{error: %{error: _error}, request: %{"redirect_uri" => redirect_uri}}, config), do: !RedirectURI.native_redirect_uri?(redirect_uri, config) + + defp can_redirect?( + %{error: %{error: _error}, request: %{"redirect_uri" => redirect_uri}}, + config + ), + do: !RedirectURI.native_redirect_uri?(redirect_uri, config) + defp can_redirect?(%{error: _}, _config), do: false defp can_redirect?(%{request: %{}}, _config), do: true end diff --git a/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex b/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex index 9d3d0199..d981179d 100644 --- a/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex +++ b/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex @@ -1,6 +1,8 @@ defmodule ExOauth2Provider.Utils.DeviceFlow do alias ExOauth2Provider.Config + @spec generate_device_code() :: binary() + @spec generate_device_code(keyword()) :: binary() def generate_device_code(config \\ [otp_app: :ex_oauth2_provider]) do config |> Config.device_flow_device_code_length() @@ -8,6 +10,8 @@ defmodule ExOauth2Provider.Utils.DeviceFlow do |> Base.url_encode64() end + @spec generate_user_code() :: binary() + @spec generate_user_code(keyword()) :: binary() def generate_user_code(config \\ [otp_app: :ex_oauth2_provider]) do # NOTE: Integer.pow only exists in elixir 1.12+ # So we have to convert erlangs pow response to integer diff --git a/mix.exs b/mix.exs index b22b19e8..c708b97b 100644 --- a/mix.exs +++ b/mix.exs @@ -11,6 +11,11 @@ defmodule ExOauth2Provider.Mixfile do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), + dialyzer: [ + list_unused_filters: true, + plt_add_apps: [:ex_unit, :mix], + plt_file: {:no_warn, "plts/ex_oauth2_provider.plt"} + ], # Hex description: "No brainer OAuth 2.0 provider", @@ -39,6 +44,7 @@ defmodule ExOauth2Provider.Mixfile do # Dev and test dependencies {:credo, "~> 1.1.0", only: [:dev, :test]}, + {:dialyxir, "~> 1.0.0", only: [:dev, :test], runtime: false}, {:ex_doc, ">= 0.0.0", only: :dev}, {:ecto_sql, "~> 3.0.0", only: [:dev, :test]}, {:plug_cowboy, "~> 2.0", only: :test}, diff --git a/mix.lock b/mix.lock index 8741e448..50c7d346 100644 --- a/mix.lock +++ b/mix.lock @@ -1,24 +1,26 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, - "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, - "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"}, - "credo": {:hex, :credo, "1.1.2", "02b6422f3e659eb74b05aca3c20c1d8da0119a05ee82577a82e6c2938bf29f81", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, - "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, - "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm"}, - "ecto": {:hex, :ecto, "3.0.9", "f01922a0b91a41d764d4e3a914d7f058d99a03460d3082c61dd2dcadd724c934", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, - "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, - "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, - "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, - "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, + "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, + "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, + "credo": {:hex, :credo, "1.1.2", "02b6422f3e659eb74b05aca3c20c1d8da0119a05ee82577a82e6c2938bf29f81", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd9322e9d391251ca3c4fac10ff0ce86ea4b99b3d9b34526ee2a7baa5928167d"}, + "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "5a0e8c1c722dbcd31c0cbd1906b1d1074c863d335c295e4b994849b65a1fbe47"}, + "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm", "52694ef56e60108e5012f8af9673874c66ed58ac1c4fae9b5b7ded31786663f5"}, + "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, + "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm", "762b999fd414fb41e297944228aa1de2cd4a3876a07f968c8b11d1e9a2190d07"}, + "ecto": {:hex, :ecto, "3.0.9", "f01922a0b91a41d764d4e3a914d7f058d99a03460d3082c61dd2dcadd724c934", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "b5f2b45d3dc6ac4053cc96dec337dc4d28ba4e2d9ad905e2237553fde25d952f"}, + "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5bd47a499d27084afaa3c2154cfedb478ea2fcc926ef59fa515ee089701e390"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "93d2fee94d2f88abf507628378371ea5fab08ed03fa59a6daa3d4469d9159ddd"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, + "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm", "de9825f21c6fd6adfdeae8f9c80dcd88c1e58301f06bf13d659b7e606b88abe0"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, + "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "0ec1f09319f29b4dfc2d0e08c776834d219faae0da3536f3d9460f6793e6af1f"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm", "63d9f37d319ff331a51f6221310deb5aac8ea3dcf5e0369d689121b5e52f72d4"}, } From af620a84f026572b7810bdc1cb533265e5ac1186 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Mon, 30 Aug 2021 11:40:54 -0700 Subject: [PATCH 05/11] [MINOR] Added DeviceGrants#authorized? and covered DeviceGrants w/ tests --- .../device_grants/device_grants.ex | 14 +- .../device_grants/device_grants_test.exs | 198 ++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 test/ex_oauth2_provider/device_grants/device_grants_test.exs diff --git a/lib/ex_oauth2_provider/device_grants/device_grants.ex b/lib/ex_oauth2_provider/device_grants/device_grants.ex index 442e2060..a8c7d630 100644 --- a/lib/ex_oauth2_provider/device_grants/device_grants.ex +++ b/lib/ex_oauth2_provider/device_grants/device_grants.ex @@ -4,6 +4,7 @@ defmodule ExOauth2Provider.DeviceGrants do """ import Ecto.Query + alias Ecto.{Changeset, Schema} alias ExOauth2Provider.Mixin.{Expirable} alias ExOauth2Provider.{ @@ -14,6 +15,8 @@ defmodule ExOauth2Provider.DeviceGrants do defdelegate is_expired?(device_grant), to: Expirable + @spec authorize(DeviceGrant.t(), Schema.t(), keyword()) :: + {:ok, Schema.t()} | {:error, Changeset.t()} def authorize(device_grant, resource_owner, config) do params = %{resource_owner_id: resource_owner.id, user_code: nil} @@ -22,6 +25,11 @@ defmodule ExOauth2Provider.DeviceGrants do |> get_repo(config).update() end + @spec authorized?(DeviceGrant.t()) :: true | false + def authorized?(device_grant) do + device_grant.user_code == nil && device_grant.resource_owner_id != nil + end + @doc """ Creates a device grant. @@ -45,6 +53,7 @@ defmodule ExOauth2Provider.DeviceGrants do |> repo.insert() end + @spec delete_expired(keyword()) :: {integer(), nil | [term()]} def delete_expired(config) do {schema, repo} = schema_and_repo_from_config(config) lifespan = Config.authorization_code_expires_in(config) @@ -56,6 +65,7 @@ defmodule ExOauth2Provider.DeviceGrants do |> repo.delete_all() end + @spec delete!(Schema.t(), keyword()) :: Schema.t() def delete!(grant, config) do get_repo(config).delete!(grant) end @@ -81,13 +91,14 @@ defmodule ExOauth2Provider.DeviceGrants do def find_by_user_code(nil, _config), do: nil - # DeviceGrant | nil + @spec find_by_user_code(binary(), keyword()) :: Schema.t() | nil def find_by_user_code(user_code, config) do config |> schema_and_repo_from_config() |> fetch_grant_with_user_code(user_code) end + @spec update_last_polled_at!(Schema.t(), keyword()) :: Schema.t() def update_last_polled_at!(grant, config) do grant |> DeviceGrant.changeset(%{last_polled_at: DateTime.utc_now()}, config) @@ -95,7 +106,6 @@ defmodule ExOauth2Provider.DeviceGrants do end defp fetch_grant_with_user_code({schema, repo}, user_code) do - # from(d in schema, preload: [:application], where: d.user_code == ^user_code) schema |> repo.get_by(user_code: user_code) |> repo.preload(:application) diff --git a/test/ex_oauth2_provider/device_grants/device_grants_test.exs b/test/ex_oauth2_provider/device_grants/device_grants_test.exs new file mode 100644 index 00000000..16e64090 --- /dev/null +++ b/test/ex_oauth2_provider/device_grants/device_grants_test.exs @@ -0,0 +1,198 @@ +defmodule ExOauth2Provider.DeviceGrantsTest do + use ExOauth2Provider.TestCase + + alias ExOauth2Provider.DeviceGrants + alias ExOauth2Provider.Test.Fixtures + alias ExOauth2Provider.Test.QueryHelpers + alias Dummy.{OauthDeviceGrants.OauthDeviceGrant, Repo, Users.User} + + @config [otp_app: :ex_oauth2_provider] + + setup do + application = Fixtures.application() + + {:ok, %{application: application}} + end + + describe "#authorize/3" do + test "updates the given grant and returns the result tuple", context do + %{application: application} = context + + grant = Fixtures.device_grant(application: application) + user = Fixtures.resource_owner() + + {:ok, updated_grant} = DeviceGrants.authorize(grant, user, @config) + + assert updated_grant.id == grant.id + assert updated_grant.resource_owner_id == user.id + assert updated_grant.user_code == nil + end + + test "returns an error tuple when the changeset is invalid", context do + %{application: application} = context + + grant = Fixtures.device_grant(application: application) + user = %User{id: "abc"} + + {:error, _changeset} = DeviceGrants.authorize(grant, user, @config) + end + end + + describe "#authorized?/1" do + test "returns true when the given schema has been authorized" do + grant = %OauthDeviceGrant{resource_owner_id: "abc", user_code: nil} + + assert DeviceGrants.authorized?(grant) == true + end + + test "returns false when the given schema is not authorized" do + grant = %OauthDeviceGrant{resource_owner_id: nil, user_code: "abc"} + + assert DeviceGrants.authorized?(grant) == false + end + end + + describe "#create_grant/3" do + test "inserts a new record and returns the result tuple", context do + %{application: application} = context + + attrs = %{ + "device_code" => "dc", + "expires_in" => 10, + "user_code" => "uc" + } + + {:ok, grant} = DeviceGrants.create_grant(application, attrs, @config) + + refute grant.id == nil + assert grant.device_code == "dc" + assert grant.expires_in == 10 + assert grant.last_polled_at == nil + # Default behavior when not specified + assert grant.scopes == "public" + assert grant.user_code == "uc" + end + + test "accepts valid scopes", %{application: application} do + attrs = %{ + "device_code" => "dc", + "expires_in" => 10, + "scopes" => "read", + "user_code" => "uc" + } + + {:ok, grant} = DeviceGrants.create_grant(application, attrs, @config) + + assert grant.scopes == "read" + end + + test "returns an error tuple when the changeset is invalid", context do + %{application: application} = context + {:error, _changeset} = DeviceGrants.create_grant(application, %{}, @config) + end + end + + describe "#delete_expired/1" do + test "deletes all expired grants and returns the result tuple", context do + %{application: application} = context + + grant = Fixtures.device_grant(application: application) + + inserted_at = + QueryHelpers.timestamp( + OauthDeviceGrant, + :inserted_at, + seconds: -grant.expires_in + ) + + QueryHelpers.change!(grant, inserted_at: inserted_at) + + {1, nil} = DeviceGrants.delete_expired(@config) + assert QueryHelpers.count(OauthDeviceGrant) == 0 + end + end + + describe "#delete!/1" do + test "deletes the grant and returns it", %{application: application} do + grant = Fixtures.device_grant(application: application) + + deleted_grant = DeviceGrants.delete!(grant, @config) + + assert deleted_grant.id == grant.id + assert QueryHelpers.count(OauthDeviceGrant) == 0 + end + + test "raises an error when the changeset is invalid" do + assert_raise Ecto.StaleEntryError, fn -> + DeviceGrants.delete!(%OauthDeviceGrant{id: 123}, @config) + end + end + end + + describe "#find_by_application_and_device_code/3" do + test "returns the matching DeviceGrant", %{application: application} do + grant = Fixtures.device_grant(application: application) + + found_grant = + DeviceGrants.find_by_application_and_device_code( + application, + grant.device_code, + @config + ) + + assert grant.id == found_grant.id + end + + test "returns the nil when no matching grant exists", %{application: application} do + result = + DeviceGrants.find_by_application_and_device_code( + application, + "foo", + @config + ) + + assert result == nil + end + end + + describe "#find_by_user_code/2" do + test "returns the grant matching the user code", %{application: application} do + grant = Fixtures.device_grant(application: application) + + found_grant = DeviceGrants.find_by_user_code(grant.user_code, @config) + + assert grant.id == found_grant.id + end + + test "returns the nil when no matching grant exists" do + result = DeviceGrants.find_by_user_code("foo", @config) + + assert result == nil + end + end + + describe "#update_last_polled_at!/2" do + test "updates last polled at timestamp and returns the updated schema", context do + %{application: application} = context + grant = Fixtures.device_grant(application: application) + assert grant.last_polled_at == nil + + grant = DeviceGrants.update_last_polled_at!(grant, @config) + + refute grant.last_polled_at == nil + end + + test "raises an error when the changeset is invalid", context do + %{application: application} = context + + grant = + [application: application] + |> Fixtures.device_grant() + |> DeviceGrants.delete!(@config) + + assert_raise Ecto.StaleEntryError, fn -> + DeviceGrants.update_last_polled_at!(grant, @config) + end + end + end +end From d0068bf7e30a48fe95f11f127f8b818c2dbdcb00 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Mon, 30 Aug 2021 15:20:27 -0700 Subject: [PATCH 06/11] [MINOR] Add Authorization#authorize_device for a cleaner API --- .../oauth2/authorization.ex | 13 +++++++ .../oauth2/authorization_test.exs | 37 ++++++++++++++----- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/lib/ex_oauth2_provider/oauth2/authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization.ex index 8138057f..b603a66c 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization.ex @@ -63,6 +63,19 @@ defmodule ExOauth2Provider.Authorization do end end + @doc """ + Authorize a device. Pass in a map with user_code for the request. + """ + @spec authorize_device(Schema.t(), map(), keyword()) :: + {:ok, Schema.t()} | Response.error() | Response.redirect() | Response.native_redirect() + def authorize_device(resource_owner, request, config \\ []) do + authorize( + resource_owner, + Map.put(request, "response_type", "device_code"), + config + ) + end + @doc """ Check ExOauth2Provider.Authorization.Code for usage. """ diff --git a/test/ex_oauth2_provider/oauth2/authorization_test.exs b/test/ex_oauth2_provider/oauth2/authorization_test.exs index 019cb954..dae011ca 100644 --- a/test/ex_oauth2_provider/oauth2/authorization_test.exs +++ b/test/ex_oauth2_provider/oauth2/authorization_test.exs @@ -2,6 +2,7 @@ defmodule ExOauth2Provider.AuthorizationTest do use ExOauth2Provider.TestCase alias ExOauth2Provider.Authorization + alias ExOauth2Provider.DeviceGrants alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} @client_id "Jf5rM8hQBc" @@ -20,6 +21,7 @@ defmodule ExOauth2Provider.AuthorizationTest do error: :unsupported_response_type, error_description: "The authorization server does not support this response type." } + @config [otp_app: :ex_oauth2_provider] setup do user = Fixtures.resource_owner() @@ -30,10 +32,12 @@ defmodule ExOauth2Provider.AuthorizationTest do {:ok, %{resource_owner: user, application: application}} end - test "#preauthorize_device/2 does stuff and junk", %{application: application} do + test "#preauthorize_device/2 returns an :ok tuple with the user code", %{ + application: application + } do response = %{"client_id" => application.uid} - |> Authorization.preauthorize_device(otp_app: :ex_oauth2_provider) + |> Authorization.preauthorize_device(@config) assert {:ok, %{user_code: _user_code}} = response end @@ -41,7 +45,7 @@ defmodule ExOauth2Provider.AuthorizationTest do test "#preauthorize/3 error when missing response_type", %{resource_owner: resource_owner} do params = Map.delete(@valid_request, "response_type") - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.preauthorize(resource_owner, params, @config) == {:error, @invalid_request, :bad_request} end @@ -58,7 +62,7 @@ defmodule ExOauth2Provider.AuthorizationTest do |> Map.delete("response_type") |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.preauthorize(resource_owner, params, @config) == {:redirect, "https://example.com/path?error=invalid_request&error_description=The+request+is+missing+a+required+parameter%2C+includes+an+unsupported+parameter+value%2C+or+is+otherwise+malformed.¶m=1&state=40612"} end @@ -66,7 +70,7 @@ defmodule ExOauth2Provider.AuthorizationTest do test "#preauthorize/3 error when unsupported response type", %{resource_owner: resource_owner} do params = Map.merge(@valid_request, %{"response_type" => "invalid"}) - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.preauthorize(resource_owner, params, @config) == {:error, @invalid_response_type, :unprocessable_entity} end @@ -83,7 +87,7 @@ defmodule ExOauth2Provider.AuthorizationTest do |> Map.merge(%{"response_type" => "invalid"}) |> Map.merge(%{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) - assert Authorization.preauthorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.preauthorize(resource_owner, params, @config) == {:redirect, "https://example.com/path?error=unsupported_response_type&error_description=The+authorization+server+does+not+support+this+response+type.¶m=1&state=40612"} end @@ -91,28 +95,41 @@ defmodule ExOauth2Provider.AuthorizationTest do test "#authorize/3 error when missing response_type", %{resource_owner: resource_owner} do params = Map.delete(@valid_request, "response_type") - assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.authorize(resource_owner, params, @config) == {:error, @invalid_request, :bad_request} end test "#authorize/3 rejects when unsupported response type", %{resource_owner: resource_owner} do params = Map.merge(@valid_request, %{"response_type" => "invalid"}) - assert Authorization.authorize(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.authorize(resource_owner, params, @config) == {:error, @invalid_response_type, :unprocessable_entity} end + test "#authorize_device/3 returns an :ok tuple with the user code", context do + %{application: application, resource_owner: resource_owner} = context + grant = Fixtures.device_grant(application: application) + + response = + resource_owner + |> Authorization.authorize_device(%{"user_code" => grant.user_code}, @config) + + assert {:ok, authorized_grant} = response + assert grant.id == authorized_grant.id + assert DeviceGrants.authorized?(authorized_grant) + end + test "#deny/3 error when missing response_type", %{resource_owner: resource_owner} do params = Map.delete(@valid_request, "response_type") - assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.deny(resource_owner, params, @config) == {:error, @invalid_request, :bad_request} end test "#deny/3 rejects when unsupported response type", %{resource_owner: resource_owner} do params = Map.merge(@valid_request, %{"response_type" => "invalid"}) - assert Authorization.deny(resource_owner, params, otp_app: :ex_oauth2_provider) == + assert Authorization.deny(resource_owner, params, @config) == {:error, @invalid_response_type, :unprocessable_entity} end end From 7849e2eb36607163d6d0975b21e65e92ef0b04d6 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Tue, 31 Aug 2021 16:01:31 -0700 Subject: [PATCH 07/11] [PATCH] Fix device grant create - Pass config down to DeviceFlow helpers during device grant creation so they abide by custom configuration --- .../device_code/device_authorization.ex | 4 +-- .../oauth2/utils/device_flow.ex | 4 +-- .../device_code/device_authorization_test.exs | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex index 631f9dd5..2b237849 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex @@ -44,10 +44,10 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization do @spec generate_grant_params(map(), keyword()) :: map() defp generate_grant_params(request, config) do %{ - device_code: DeviceFlow.generate_device_code(), + device_code: DeviceFlow.generate_device_code(config), expires_in: Config.authorization_code_expires_in(config), scopes: Map.get(request, "scope"), - user_code: DeviceFlow.generate_user_code() + user_code: DeviceFlow.generate_user_code(config) } end diff --git a/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex b/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex index d981179d..729f5826 100644 --- a/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex +++ b/lib/ex_oauth2_provider/oauth2/utils/device_flow.ex @@ -3,7 +3,7 @@ defmodule ExOauth2Provider.Utils.DeviceFlow do @spec generate_device_code() :: binary() @spec generate_device_code(keyword()) :: binary() - def generate_device_code(config \\ [otp_app: :ex_oauth2_provider]) do + def generate_device_code(config \\ []) do config |> Config.device_flow_device_code_length() |> :crypto.strong_rand_bytes() @@ -12,7 +12,7 @@ defmodule ExOauth2Provider.Utils.DeviceFlow do @spec generate_user_code() :: binary() @spec generate_user_code(keyword()) :: binary() - def generate_user_code(config \\ [otp_app: :ex_oauth2_provider]) do + def generate_user_code(config \\ []) do # NOTE: Integer.pow only exists in elixir 1.12+ # So we have to convert erlangs pow response to integer # Thanks to Doorkeeper for this! diff --git a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs index 246740ff..f8f0d203 100644 --- a/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs +++ b/test/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization_test.exs @@ -168,4 +168,38 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorizationTest do DeviceAuthorization.process_request(request, @config) end end + + describe "#process_request when values are configured" do + test "it generates the grant based on the config", context do + %{application: application} = context + + request = %{ + "client_id" => application.uid, + "response_type" => "device_code" + } + + custom_config = + Keyword.merge( + @config, + authorization_code_expires_in: 60, + device_flow_device_code_length: 10, + device_flow_polling_interval: 30, + device_flow_user_code_length: 4 + ) + + {:ok, + %{ + device_code: device_code, + expires_in: expires_in, + interval: interval, + user_code: user_code + }} = DeviceAuthorization.process_request(request, custom_config) + + # Base64 encoded 10 char string is 16 long. + assert String.length(device_code) == 16 + assert expires_in == 60 + assert interval == 30 + assert String.length(user_code) == 4 + end + end end From 6d71d33b5205ac47191ffd0418dd2da529d6a2ff Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Thu, 2 Sep 2021 10:23:56 -0700 Subject: [PATCH 08/11] [PATCH] Fix typespecs for responses since device flow differs --- .../oauth2/authorization.ex | 11 +++- .../oauth2/authorization/strategy/code.ex | 60 ++++++++++++++----- .../authorization/strategy/device_code.ex | 15 +++-- .../device_code/device_authorization.ex | 9 ++- .../strategy/device_code/user_interaction.ex | 12 ++-- .../oauth2/authorization/utils/response.ex | 12 ++-- .../device_grants/device_grants_test.exs | 2 +- 7 files changed, 90 insertions(+), 31 deletions(-) diff --git a/lib/ex_oauth2_provider/oauth2/authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization.ex index b603a66c..8b237b94 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization.ex @@ -18,7 +18,11 @@ defmodule ExOauth2Provider.Authorization do does not have a user, there is no session. """ @spec preauthorize(Schema.t() | nil, map(), keyword()) :: - Response.success() | Response.error() | Response.redirect() | Response.native_redirect() + Response.preauthorization_success() + | Response.device_preauthorization_success() + | Response.error() + | Response.redirect() + | Response.native_redirect() def preauthorize(resource_owner, request, config \\ []) do case validate_response_type(request, config) do {:error, :invalid_response_type} -> @@ -36,7 +40,10 @@ defmodule ExOauth2Provider.Authorization do Authorize access for a device. See #preauthorize/3 for more details. """ @spec preauthorize_device(map(), keyword()) :: - Response.success() | Response.error() | Response.redirect() | Response.native_redirect() + Response.device_preauthorization_success() + | Response.error() + | Response.redirect() + | Response.native_redirect() def preauthorize_device(request, config \\ []) do # This wraps preauthorize but since there's no user or response type # in these requests we can pass what's needed. diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex index 40b3cb02..42c9f294 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex @@ -63,7 +63,9 @@ defmodule ExOauth2Provider.Authorization.Code do Authorization.Utils.Response, RedirectURI, Scopes, - Utils.Error} + Utils.Error + } + alias Ecto.Schema @doc """ @@ -85,7 +87,11 @@ defmodule ExOauth2Provider.Authorization.Code do {:redirect, redirect_uri} # Redirect {:native_redirect, %{code: code}} # Redirect to :show page """ - @spec preauthorize(Schema.t(), map(), keyword()) :: Response.success() | Response.error() | Response.redirect() | Response.native_redirect() + @spec preauthorize(Schema.t(), map(), keyword()) :: + Response.preauthorization_success() + | Response.error() + | Response.redirect() + | Response.native_redirect() def preauthorize(resource_owner, request, config \\ []) do resource_owner |> Utils.prehandle_request(request, config) @@ -96,15 +102,24 @@ defmodule ExOauth2Provider.Authorization.Code do end defp check_previous_authorization({:error, params}, _config), do: {:error, params} - defp check_previous_authorization({:ok, %{resource_owner: resource_owner, client: application, request: %{"scope" => scopes}} = params}, config) do + + defp check_previous_authorization( + {:ok, + %{resource_owner: resource_owner, client: application, request: %{"scope" => scopes}} = + params}, + config + ) do case AccessTokens.get_token_for(resource_owner, application, scopes, config) do - nil -> {:ok, params} + nil -> {:ok, params} token -> {:ok, Map.put(params, :access_token, token)} end end defp reissue_grant({:error, params}, _config), do: {:error, params} - defp reissue_grant({:ok, %{access_token: _access_token} = params}, config), do: issue_grant({:ok, params}, config) + + defp reissue_grant({:ok, %{access_token: _access_token} = params}, config), + do: issue_grant({:ok, params}, config) + defp reissue_grant({:ok, params}, _config), do: {:ok, params} @doc """ @@ -129,7 +144,11 @@ defmodule ExOauth2Provider.Authorization.Code do {:redirect, redirect_uri} # Redirect {:native_redirect, %{code: code}} # Redirect to :show page """ - @spec authorize(Schema.t(), map(), keyword()) :: Response.success() | Response.error() | Response.redirect() | Response.native_redirect() + @spec authorize(Schema.t(), map(), keyword()) :: + Response.authorization_success() + | Response.error() + | Response.redirect() + | Response.native_redirect() def authorize(resource_owner, request, config \\ []) do resource_owner |> Utils.prehandle_request(request, config) @@ -139,20 +158,24 @@ defmodule ExOauth2Provider.Authorization.Code do end defp issue_grant({:error, %{error: _error} = params}, _config), do: {:error, params} - defp issue_grant({:ok, %{resource_owner: resource_owner, client: application, request: request} = params}, config) do + + defp issue_grant( + {:ok, %{resource_owner: resource_owner, client: application, request: request} = params}, + config + ) do grant_params = request |> Map.take(["redirect_uri", "scope"]) |> Map.new(fn {k, v} -> case k do "scope" -> {:scopes, v} - _ -> {String.to_atom(k), v} + _ -> {String.to_atom(k), v} end end) |> Map.put(:expires_in, Config.authorization_code_expires_in(config)) case AccessGrants.create_grant(resource_owner, application, grant_params, config) do - {:ok, grant} -> {:ok, Map.put(params, :grant, grant)} + {:ok, grant} -> {:ok, Map.put(params, :grant, grant)} {:error, error} -> Error.add_error({:ok, params}, error) end end @@ -183,6 +206,7 @@ defmodule ExOauth2Provider.Authorization.Code do end defp validate_request({:error, params}, _config), do: {:error, params} + defp validate_request({:ok, params}, config) do {:ok, params} |> validate_resource_owner() @@ -193,26 +217,32 @@ defmodule ExOauth2Provider.Authorization.Code do defp validate_resource_owner({:ok, %{resource_owner: resource_owner} = params}) do case resource_owner do %{__struct__: _} -> {:ok, params} - _ -> Error.add_error({:ok, params}, Error.invalid_request()) + _ -> Error.add_error({:ok, params}, Error.invalid_request()) end end defp validate_scopes({:error, params}, _config), do: {:error, params} + defp validate_scopes({:ok, %{request: %{"scope" => scopes}, client: client} = params}, config) do - scopes = Scopes.to_list(scopes) + scopes = Scopes.to_list(scopes) + server_scopes = client.scopes |> Scopes.to_list() |> Scopes.default_to_server_scopes(config) case Scopes.all?(server_scopes, scopes) do - true -> {:ok, params} + true -> {:ok, params} false -> Error.add_error({:ok, params}, Error.invalid_scopes()) end end defp validate_redirect_uri({:error, params}, _config), do: {:error, params} - defp validate_redirect_uri({:ok, %{request: %{"redirect_uri" => redirect_uri}, client: client} = params}, config) do + + defp validate_redirect_uri( + {:ok, %{request: %{"redirect_uri" => redirect_uri}, client: client} = params}, + config + ) do cond do RedirectURI.native_redirect_uri?(redirect_uri, config) -> {:ok, params} @@ -224,5 +254,7 @@ defmodule ExOauth2Provider.Authorization.Code do Error.add_error({:ok, params}, Error.invalid_redirect_uri()) end end - defp validate_redirect_uri({:ok, params}, _config), do: Error.add_error({:ok, params}, Error.invalid_request()) + + defp validate_redirect_uri({:ok, params}, _config), + do: Error.add_error({:ok, params}, Error.invalid_request()) end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex index 152ad417..b9c9526c 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code.ex @@ -1,19 +1,26 @@ defmodule ExOauth2Provider.Authorization.DeviceCode do + @moduledoc """ + This module is the glue for the various steps and integrates it into + the authorization architecture that this package provides. It separates + the concerns to simplify things. + """ + alias Ecto.Schema alias ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization alias ExOauth2Provider.Authorization.DeviceCode.UserInteraction - - # NOTE: This module is the glue for the various steps and integrates it into - # the authorization architecture that this package provides. It separates - # the concerns to simplify things. + alias ExOauth2Provider.Authorization.Utils.Response # User Interaction Request - approve the grant with user code # https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + @spec authorize(Schema.t(), map(), keyword()) :: + Response.authorization_success() | Response.error() def authorize(resource_owner, request, config \\ []) do UserInteraction.process_request(resource_owner, request, config) end # Device Authorization Request # https://tools.ietf.org/html/rfc8628#section-3.1 + @spec preauthorize(Schema.t(), map(), keyword()) :: + Response.device_preauthorization_success() | Response.error() def preauthorize(_resource_owner, request, config \\ []) do DeviceAuthorization.process_request(request, config) end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex index 2b237849..826fd193 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/device_authorization.ex @@ -1,8 +1,13 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization do + @moduledoc """ + Device authorization request handler + https://tools.ietf.org/html/rfc8628#section-3.1 + """ alias ExOauth2Provider.{ Config, DeviceGrants, Authorization.Utils, + Authorization.Utils.Response, Utils.DeviceFlow, Utils.Error, Utils.Validation @@ -10,8 +15,8 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.DeviceAuthorization do @required_params ~w(client_id) - # Device Authorization Request - # https://tools.ietf.org/html/rfc8628#section-3.1 + @spec process_request(map(), keyword()) :: + Response.device_preauthorization_success() | Response.error() def process_request(request, config \\ []) do %{config: config, request: request} |> Validation.validate_required_query_params(@required_params) diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex index f6a9064a..4be27033 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/device_code/user_interaction.ex @@ -1,6 +1,10 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.UserInteraction do - alias Ecto.Changeset - alias ExOauth2Provider.DeviceGrants + @moduledoc """ + User Interaction Request - approve the grant with user code + https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + """ + alias Ecto.{Changeset, Schema} + alias ExOauth2Provider.{Authorization.Utils.Response, DeviceGrants} @message_lookup [ expired_user_code: "The user_code has expired.", @@ -15,8 +19,8 @@ defmodule ExOauth2Provider.Authorization.DeviceCode.UserInteraction do user_code_missing: :bad_request ] - # User Interaction Request - approve the grant with user code - # https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + @spec process_request(Schema.t(), map(), keyword()) :: + Response.device_authorization_success() | Response.error() def process_request(owner, request, config \\ []) do %{config: config, owner: owner, user_code: Map.get(request, "user_code")} |> find_device_grant() diff --git a/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex b/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex index 4361d2d9..6cbb4a35 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/utils/response.ex @@ -1,12 +1,16 @@ defmodule ExOauth2Provider.Authorization.Utils.Response do @moduledoc false + alias Ecto.Schema alias ExOauth2Provider.{RedirectURI, Scopes, Utils} + @type authorization_success :: {:ok, map()} + @type device_authorization_success :: {:ok, Schema.t()} + @type device_preauthorization_success :: {:ok, map()} + @type error :: {:error, map(), integer()} @type native_redirect :: {:native_redirect, %{code: binary()}} + @type preauthorization_success :: {:ok, Schema.t(), list()} @type redirect :: {:redirect, binary()} - @type error :: {:error, map(), integer()} - @type success :: {:ok, map()} @doc false @spec error_response({:error, map()}, keyword()) :: error() | redirect() | native_redirect() @@ -15,7 +19,7 @@ defmodule ExOauth2Provider.Authorization.Utils.Response do @doc false @spec preauthorize_response({:ok, map()} | {:error, map()}, keyword()) :: - success() | error() | redirect() | native_redirect() + preauthorization_success() | error() | redirect() | native_redirect() def preauthorize_response({:ok, %{grant: grant} = params}, config), do: build_response(params, %{code: grant.token}, config) @@ -27,7 +31,7 @@ defmodule ExOauth2Provider.Authorization.Utils.Response do @doc false @spec authorize_response({:ok, map()} | {:error, map()}, keyword()) :: - success() | error() | redirect() | native_redirect() + authorization_success() | error() | redirect() | native_redirect() def authorize_response({:ok, %{grant: grant} = params}, config), do: build_response(params, %{code: grant.token}, config) diff --git a/test/ex_oauth2_provider/device_grants/device_grants_test.exs b/test/ex_oauth2_provider/device_grants/device_grants_test.exs index 16e64090..086f965a 100644 --- a/test/ex_oauth2_provider/device_grants/device_grants_test.exs +++ b/test/ex_oauth2_provider/device_grants/device_grants_test.exs @@ -4,7 +4,7 @@ defmodule ExOauth2Provider.DeviceGrantsTest do alias ExOauth2Provider.DeviceGrants alias ExOauth2Provider.Test.Fixtures alias ExOauth2Provider.Test.QueryHelpers - alias Dummy.{OauthDeviceGrants.OauthDeviceGrant, Repo, Users.User} + alias Dummy.{OauthDeviceGrants.OauthDeviceGrant, Users.User} @config [otp_app: :ex_oauth2_provider] From e34423b68727e4e26f31b735ca43e669bdab23d4 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Wed, 8 Sep 2021 10:24:14 -0700 Subject: [PATCH 09/11] [MINOR] Add skip authorization feature --- .../access_grants/access_grants.ex | 32 +- .../applications/application.ex | 27 +- .../behaviors/skip_authorization.ex | 23 ++ lib/ex_oauth2_provider/config.ex | 35 +++ lib/ex_oauth2_provider/features.ex | 8 + .../oauth2/authorization/strategy/code.ex | 14 + .../applications/application_test.exs | 23 +- .../authorization/strategy/code_test.exs | 289 ++++++++++++++---- .../ex_oauth2_provider.gen.migration_test.exs | 4 + 9 files changed, 375 insertions(+), 80 deletions(-) create mode 100644 lib/ex_oauth2_provider/behaviors/skip_authorization.ex create mode 100644 lib/ex_oauth2_provider/features.ex diff --git a/lib/ex_oauth2_provider/access_grants/access_grants.ex b/lib/ex_oauth2_provider/access_grants/access_grants.ex index 9eab4bb5..261b275f 100644 --- a/lib/ex_oauth2_provider/access_grants/access_grants.ex +++ b/lib/ex_oauth2_provider/access_grants/access_grants.ex @@ -23,11 +23,10 @@ defmodule ExOauth2Provider.AccessGrants do """ @spec get_active_grant_for(Application.t(), binary(), keyword()) :: AccessGrant.t() | nil def get_active_grant_for(application, token, config \\ []) do - config - |> Config.access_grant() - |> Config.repo(config).get_by(application_id: application.id, token: token) - |> Expirable.filter_expired() - |> Revocable.filter_revoked() + get_active_grant_with_criteria( + [application_id: application.id, token: token], + config + ) end @doc """ @@ -42,7 +41,8 @@ defmodule ExOauth2Provider.AccessGrants do {:error, %Ecto.Changeset{}} """ - @spec create_grant(Ecto.Schema.t(), Application.t(), map(), keyword()) :: {:ok, AccessGrant.t()} | {:error, term()} + @spec create_grant(Ecto.Schema.t(), Application.t(), map(), keyword()) :: + {:ok, AccessGrant.t()} | {:error, term()} def create_grant(resource_owner, application, attrs, config \\ []) do config |> Config.access_grant() @@ -50,4 +50,24 @@ defmodule ExOauth2Provider.AccessGrants do |> AccessGrant.changeset(attrs, config) |> Config.repo(config).insert() end + + @doc """ + Retrieve active grant for the given resource owner and token. + """ + @spec get_active_grant_for_owner_by_token(Ecto.Schema.t(), binary(), keyword()) :: + AccessGrant.t() | nil + def get_active_grant_for_owner_by_token(owner, token, config \\ []) do + get_active_grant_with_criteria( + [token: token, resource_owner_id: owner.id], + config + ) + end + + defp get_active_grant_with_criteria(criteria, config) do + config + |> Config.access_grant() + |> Config.repo(config).get_by(criteria) + |> Expirable.filter_expired() + |> Revocable.filter_revoked() + end end diff --git a/lib/ex_oauth2_provider/applications/application.ex b/lib/ex_oauth2_provider/applications/application.ex index 4f342201..18fdbf6b 100644 --- a/lib/ex_oauth2_provider/applications/application.ex +++ b/lib/ex_oauth2_provider/applications/application.ex @@ -42,11 +42,12 @@ defmodule ExOauth2Provider.Applications.Application do @doc false def attrs() do [ - {:name, :string, null: false}, - {:uid, :string, null: false}, - {:secret, :string, null: false, default: ""}, - {:redirect_uri, :string, null: false}, - {:scopes, :string, null: false, default: ""}, + {:is_trusted, :boolean, null: false, default: false}, + {:name, :string, null: false}, + {:redirect_uri, :string, null: false}, + {:scopes, :string, null: false, default: ""}, + {:secret, :string, null: false, default: ""}, + {:uid, :string, null: false} ] end @@ -67,7 +68,7 @@ defmodule ExOauth2Provider.Applications.Application do use ExOauth2Provider.Schema, unquote(config) # For Phoenix integrations - if Code.ensure_loaded?(Phoenix.Param), do: @derive {Phoenix.Param, key: :uid} + if Code.ensure_loaded?(Phoenix.Param), do: @derive({Phoenix.Param, key: :uid}) import unquote(__MODULE__), only: [application_fields: 0] end @@ -87,7 +88,7 @@ defmodule ExOauth2Provider.Applications.Application do def changeset(application, params, config \\ []) do application |> maybe_new_application_changeset(params, config) - |> Changeset.cast(params, [:name, :secret, :redirect_uri, :scopes]) + |> Changeset.cast(params, [:is_trusted, :name, :secret, :redirect_uri, :scopes]) |> Changeset.validate_required([:name, :uid, :redirect_uri]) |> validate_secret_not_nil() |> Scopes.validate_scopes(nil, config) @@ -98,13 +99,13 @@ defmodule ExOauth2Provider.Applications.Application do defp validate_secret_not_nil(changeset) do case Changeset.get_field(changeset, :secret) do nil -> Changeset.add_error(changeset, :secret, "can't be blank") - _ -> changeset + _ -> changeset end end defp maybe_new_application_changeset(application, params, config) do case Ecto.get_meta(application, :state) do - :built -> new_application_changeset(application, params, config) + :built -> new_application_changeset(application, params, config) :loaded -> application end end @@ -127,18 +128,20 @@ defmodule ExOauth2Provider.Applications.Application do url |> RedirectURI.validate(config) |> case do - {:error, error} -> Changeset.add_error(changeset, :redirect_uri, error) - {:ok, _} -> changeset - end + {:error, error} -> Changeset.add_error(changeset, :redirect_uri, error) + {:ok, _} -> changeset + end end) end defp put_uid(%{changes: %{uid: _}} = changeset), do: changeset + defp put_uid(%{} = changeset) do Changeset.change(changeset, %{uid: Utils.generate_token()}) end defp put_secret(%{changes: %{secret: _}} = changeset), do: changeset + defp put_secret(%{} = changeset) do Changeset.change(changeset, %{secret: Utils.generate_token()}) end diff --git a/lib/ex_oauth2_provider/behaviors/skip_authorization.ex b/lib/ex_oauth2_provider/behaviors/skip_authorization.ex new file mode 100644 index 00000000..e8564e4a --- /dev/null +++ b/lib/ex_oauth2_provider/behaviors/skip_authorization.ex @@ -0,0 +1,23 @@ +defmodule ExOauth2Provider.Behaviors.SkipAuthorization do + @moduledoc """ + Define the rules used to determine if authorization can be skipped. If your + app has unique criteria then implement it. + + For example: + + defmodule MyModule do + @behaviour ExOauth2Provider.Behaviors.SkipAuthorization + + def skip_authorization(user, application) do + user.super_cool? || application.trusted? + end + end + """ + alias ExOauth2Provider.Applications.Application + alias ExOauth2Provider.Schema + + @callback skip_authorization?( + user :: Schema.t(), + application :: Application.t() + ) :: boolean() +end diff --git a/lib/ex_oauth2_provider/config.ex b/lib/ex_oauth2_provider/config.ex index 9d32ce50..68a14ed6 100644 --- a/lib/ex_oauth2_provider/config.ex +++ b/lib/ex_oauth2_provider/config.ex @@ -178,6 +178,41 @@ defmodule ExOauth2Provider.Config do device_flow_verification_uri: "https://really.cool.site/device" """) + @doc """ + This returns the function to use to determine if we should skip authorization + and automatically grant an authorization token. This is disabled by default. + + To implement it you can set :skip_authorization in the config to any function + you wish to use to determine if it applies to the given user and/or application. + + The behavior ExOauth2Provider.Behaviors.SkipAuthorization is provided to help + facilitate proper implementation. + + For example: + + config :my_app, ExOauth2Provider, + skip_authorization_with: &MyModule.my_function/2 + + + Then you can do whatever you want with your implementation! + + defmodule MyModule do + @behaviour ExOauth2Provider.Behaviors.SkipAuthorization + + def skip_authorization(user, application) do + user.super_cool? || application.trusted? + end + end + """ + @spec skip_authorization(keyword()) :: function() + def skip_authorization(config) do + get( + config, + :skip_authorization_with, + &ExOauth2Provider.Features.skip_authorization?/2 + ) + end + defp get(config, key, value \\ nil) do otp_app = Keyword.get(config, :otp_app) diff --git a/lib/ex_oauth2_provider/features.ex b/lib/ex_oauth2_provider/features.ex new file mode 100644 index 00000000..7af5844d --- /dev/null +++ b/lib/ex_oauth2_provider/features.ex @@ -0,0 +1,8 @@ +defmodule ExOauth2Provider.Features do + @moduledoc """ + Determine the status of configurable features. + """ + @behaviour ExOauth2Provider.Behaviors.SkipAuthorization + + def skip_authorization?(_user, _application), do: false +end diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex index 42c9f294..cb75ad26 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex @@ -98,6 +98,7 @@ defmodule ExOauth2Provider.Authorization.Code do |> validate_request(config) |> check_previous_authorization(config) |> reissue_grant(config) + |> skip_authorization_if_applicable(config) |> Response.preauthorize_response(config) end @@ -122,6 +123,19 @@ defmodule ExOauth2Provider.Authorization.Code do defp reissue_grant({:ok, params}, _config), do: {:ok, params} + defp skip_authorization_if_applicable({:error, _params} = error, _config), do: error + + defp skip_authorization_if_applicable({:ok, %{grant: _grant}} = payload, _config), do: payload + + defp skip_authorization_if_applicable({:ok, params}, config) do + %{client: application, resource_owner: user} = params + + case Config.skip_authorization(config).(user, application) do + true -> issue_grant({:ok, params}, config) + false -> {:ok, params} + end + end + @doc """ Authorizes an authorization code flow request. diff --git a/test/ex_oauth2_provider/applications/application_test.exs b/test/ex_oauth2_provider/applications/application_test.exs index 07d7d24d..896e279d 100644 --- a/test/ex_oauth2_provider/applications/application_test.exs +++ b/test/ex_oauth2_provider/applications/application_test.exs @@ -2,7 +2,9 @@ defmodule ExOauth2Provider.Applications.ApplicationTest do use ExOauth2Provider.TestCase alias ExOauth2Provider.Applications.Application + alias ExOauth2Provider.Test.Fixtures alias Dummy.OauthApplications.OauthApplication + alias Dummy.Repo describe "changeset/2 with existing application" do setup do @@ -35,11 +37,8 @@ defmodule ExOauth2Provider.Applications.ApplicationTest do end test "require valid redirect uri", %{application: application} do - ["", - "invalid", - "https://example.com invalid", - "https://example.com http://example.com"] - |> Enum.each(fn(redirect_uri) -> + ["", "invalid", "https://example.com invalid", "https://example.com http://example.com"] + |> Enum.each(fn redirect_uri -> changeset = Application.changeset(application, %{redirect_uri: redirect_uri}) assert changeset.errors[:redirect_uri] end) @@ -49,6 +48,15 @@ defmodule ExOauth2Provider.Applications.ApplicationTest do changeset = Application.changeset(application, %{scopes: ""}) refute changeset.errors[:scopes] end + + test "allows is_trusted to be changed" do + app = + Fixtures.application() + |> Application.changeset(%{is_trusted: true}) + |> Repo.update!() + + assert app.is_trusted == true + end end defmodule OverrideOwner do @@ -63,7 +71,7 @@ defmodule ExOauth2Provider.Applications.ApplicationTest do end schema "oauth_applications" do - belongs_to :owner, __MODULE__ + belongs_to(:owner, __MODULE__) application_fields() timestamps() @@ -71,6 +79,7 @@ defmodule ExOauth2Provider.Applications.ApplicationTest do end test "with overridden `:owner`" do - assert %Ecto.Association.BelongsTo{owner: OverrideOwner} = OverrideOwner.__schema__(:association, :owner) + assert %Ecto.Association.BelongsTo{owner: OverrideOwner} = + OverrideOwner.__schema__(:association, :owner) end end diff --git a/test/ex_oauth2_provider/oauth2/authorization/strategy/code_test.exs b/test/ex_oauth2_provider/oauth2/authorization/strategy/code_test.exs index ca6d2d23..4e1d50fd 100644 --- a/test/ex_oauth2_provider/oauth2/authorization/strategy/code_test.exs +++ b/test/ex_oauth2_provider/oauth2/authorization/strategy/code_test.exs @@ -1,27 +1,40 @@ defmodule ExOauth2Provider.Authorization.CodeTest do use ExOauth2Provider.TestCase + alias Ecto.Changeset alias ExOauth2Provider.{Authorization, Config, Scopes} alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} alias Dummy.{OauthAccessGrants.OauthAccessGrant, Repo} - @client_id "Jf5rM8hQBc" - @valid_request %{"client_id" => @client_id, "response_type" => "code", "scope" => "app:read app:write"} - @invalid_request %{error: :invalid_request, - error_description: "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." - } - @invalid_client %{error: :invalid_client, - error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." - } - @invalid_scope %{error: :invalid_scope, - error_description: "The requested scope is invalid, unknown, or malformed." - } - @invalid_redirect_uri %{error: :invalid_redirect_uri, - error_description: "The redirect uri included is not valid." - } - @access_denied %{error: :access_denied, - error_description: "The resource owner or authorization server denied the request." - } + @config [otp_app: :ex_oauth2_provider] + @client_id "Jf5rM8hQBc" + @valid_request %{ + "client_id" => @client_id, + "response_type" => "code", + "scope" => "app:read app:write" + } + @invalid_request %{ + error: :invalid_request, + error_description: + "The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed." + } + @invalid_client %{ + error: :invalid_client, + error_description: + "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." + } + @invalid_scope %{ + error: :invalid_scope, + error_description: "The requested scope is invalid, unknown, or malformed." + } + @invalid_redirect_uri %{ + error: :invalid_redirect_uri, + error_description: "The redirect uri included is not valid." + } + @access_denied %{ + error: :access_denied, + error_description: "The resource owner or authorization server denied the request." + } setup do resource_owner = Fixtures.resource_owner() @@ -30,50 +43,70 @@ defmodule ExOauth2Provider.Authorization.CodeTest do end test "#preauthorize/3 error when no resource owner" do - assert Authorization.preauthorize(nil, @valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.preauthorize(nil, @valid_request, @config) == + {:error, @invalid_request, :bad_request} end test "#preauthorize/3 error when no client_id", %{resource_owner: resource_owner} do request = Map.delete(@valid_request, "client_id") - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:error, @invalid_request, :bad_request} end test "#preauthorize/3 error when invalid client", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"client_id" => "invalid"}) - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_client, :unprocessable_entity} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:error, @invalid_client, :unprocessable_entity} end test "#preauthorize/3", %{resource_owner: resource_owner, application: application} do expected_scopes = Scopes.to_list(@valid_request["scope"]) - assert Authorization.preauthorize(resource_owner, @valid_request, otp_app: :ex_oauth2_provider) == {:ok, application, expected_scopes} + assert Authorization.preauthorize(resource_owner, @valid_request, @config) == + {:ok, application, expected_scopes} end - test "#preauthorize/3 when previous access token with different application scopes", %{resource_owner: resource_owner, application: application} do - access_token = Fixtures.access_token(resource_owner: resource_owner, application: application, scopes: "app:read") + test "#preauthorize/3 when previous access token with different application scopes", %{ + resource_owner: resource_owner, + application: application + } do + access_token = + Fixtures.access_token( + resource_owner: resource_owner, + application: application, + scopes: "app:read" + ) + expected_scopes = Scopes.to_list(@valid_request["scope"]) - assert Authorization.preauthorize(resource_owner, @valid_request, otp_app: :ex_oauth2_provider) == {:ok, application, expected_scopes} + assert Authorization.preauthorize(resource_owner, @valid_request, @config) == + {:ok, application, expected_scopes} QueryHelpers.change!(access_token, scopes: "app:read app:write") request = Map.merge(@valid_request, %{"scope" => "app:read"}) expected_scopes = Scopes.to_list(request["scope"]) - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:ok, application, expected_scopes} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:ok, application, expected_scopes} end - test "#preauthorize/3 with limited scope", %{resource_owner: resource_owner, application: application} do + test "#preauthorize/3 with limited scope", %{ + resource_owner: resource_owner, + application: application + } do request = Map.merge(@valid_request, %{"scope" => "app:read"}) - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:ok, application, ["app:read"]} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:ok, application, ["app:read"]} end test "#preauthorize/3 error when invalid scope", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"scope" => "app:invalid"}) - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_scope, :unprocessable_entity} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:error, @invalid_scope, :unprocessable_entity} end describe "#preauthorize/3 when application has no scope" do @@ -86,45 +119,157 @@ defmodule ExOauth2Provider.Authorization.CodeTest do test "with limited server scope", %{resource_owner: resource_owner, application: application} do request = Map.merge(@valid_request, %{"scope" => "read"}) - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:ok, application, ["read"]} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:ok, application, ["read"]} end test "error when invalid server scope", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"scope" => "invalid"}) - assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_scope, :unprocessable_entity} + assert Authorization.preauthorize(resource_owner, request, @config) == + {:error, @invalid_scope, :unprocessable_entity} end end - test "#preauthorize/3 when previous access token with same scopes", %{resource_owner: resource_owner, application: application} do - Fixtures.access_token(resource_owner: resource_owner, application: application, scopes: @valid_request["scope"]) + test "#preauthorize/3 when previous access token with same scopes", %{ + resource_owner: resource_owner, + application: application + } do + Fixtures.access_token( + resource_owner: resource_owner, + application: application, + scopes: @valid_request["scope"] + ) + + assert {:native_redirect, %{code: code}} = + Authorization.preauthorize(resource_owner, @valid_request, + otp_app: :ex_oauth2_provider + ) - assert {:native_redirect, %{code: code}} = Authorization.preauthorize(resource_owner, @valid_request, otp_app: :ex_oauth2_provider) access_grant = QueryHelpers.get_latest_inserted(OauthAccessGrant) assert code == access_grant.token end + describe "#preauthorize/3 when :skip_authorization_with is configured to be true" do + setup %{application: application, resource_owner: resource_owner} do + { + :ok, + %{ + application: application, + config: + Keyword.put( + @config, + :skip_authorization_with, + fn _user, _application -> + true + end + ), + resource_owner: resource_owner + } + } + end + + test "creates the grant and responds with native_redirect when url is native", context do + %{ + application: application, + config: config, + resource_owner: resource_owner + } = context + + assert {:native_redirect, %{code: code}} = + Authorization.preauthorize( + resource_owner, + @valid_request, + config + ) + + access_grant = + Repo.get_by( + OauthAccessGrant, + application_id: application.id, + resource_owner_id: resource_owner.id, + token: code + ) + + refute access_grant == nil + end + + test "creates the grant andresponds with redirect when url is not native", context do + %{ + application: application, + config: config, + resource_owner: resource_owner + } = context + + application + |> Changeset.change(redirect_uri: "https://foo.test/callback") + |> Repo.update!() + + assert {:redirect, url} = + Authorization.preauthorize( + resource_owner, + @valid_request, + config + ) + + access_grant = + Repo.get_by( + OauthAccessGrant, + application_id: application.id, + resource_owner_id: resource_owner.id + ) + + assert url =~ access_grant.token + end + + test "pre-validation errors are passed through", context do + %{config: config, resource_owner: resource_owner} = context + + assert {:error, @invalid_client, _status} = + Authorization.preauthorize( + resource_owner, + Map.put(@valid_request, "client_id", "foo"), + config + ) + end + + test "request validation errors are passed through", context do + %{config: config, resource_owner: resource_owner} = context + + assert {:error, @invalid_redirect_uri, _status} = + Authorization.preauthorize( + resource_owner, + Map.put(@valid_request, "redirect_uri", "foo"), + config + ) + end + end + test "#authorize/3 rejects when no resource owner" do - assert Authorization.authorize(nil, @valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.authorize(nil, @valid_request, @config) == + {:error, @invalid_request, :bad_request} end test "#authorize/3 error when invalid client", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"client_id" => "invalid"}) - assert Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_client, :unprocessable_entity} + assert Authorization.authorize(resource_owner, request, @config) == + {:error, @invalid_client, :unprocessable_entity} end test "#authorize/3 error when no client_id", %{resource_owner: resource_owner} do request = Map.delete(@valid_request, "client_id") - assert Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.authorize(resource_owner, request, @config) == + {:error, @invalid_request, :bad_request} end test "#authorize/3 error when invalid scope", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"scope" => "app:read app:profile"}) - assert Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_scope, :unprocessable_entity} + assert Authorization.authorize(resource_owner, request, @config) == + {:error, @invalid_scope, :unprocessable_entity} end describe "#authorize/3 when application has no scope" do @@ -136,12 +281,16 @@ defmodule ExOauth2Provider.Authorization.CodeTest do test "error when invalid server scope", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"scope" => "public profile"}) - assert Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_scope, :unprocessable_entity} + + assert Authorization.authorize(resource_owner, request, @config) == + {:error, @invalid_scope, :unprocessable_entity} end test "generates grant", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"scope" => "public"}) - assert {:native_redirect, %{code: code}} = Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) + + assert {:native_redirect, %{code: code}} = + Authorization.authorize(resource_owner, request, @config) access_grant = Repo.get_by(OauthAccessGrant, token: code) assert access_grant.resource_owner_id == resource_owner.id @@ -151,53 +300,83 @@ defmodule ExOauth2Provider.Authorization.CodeTest do test "#authorize/3 error when invalid redirect uri", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"redirect_uri" => "/invalid/path"}) - assert Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_redirect_uri, :unprocessable_entity} + assert Authorization.authorize(resource_owner, request, @config) == + {:error, @invalid_redirect_uri, :unprocessable_entity} end test "#authorize/3 generates grant", %{resource_owner: resource_owner} do - assert {:native_redirect, %{code: code}} = Authorization.authorize(resource_owner, @valid_request, otp_app: :ex_oauth2_provider) + assert {:native_redirect, %{code: code}} = + Authorization.authorize(resource_owner, @valid_request, @config) + access_grant = Repo.get_by(OauthAccessGrant, token: code) assert access_grant.resource_owner_id == resource_owner.id - assert access_grant.expires_in == Config.authorization_code_expires_in(otp_app: :ex_oauth2_provider) + + assert access_grant.expires_in == + Config.authorization_code_expires_in(otp_app: :ex_oauth2_provider) + assert access_grant.scopes == @valid_request["scope"] end - test "#authorize/3 generates grant with redirect uri", %{resource_owner: resource_owner, application: application} do - QueryHelpers.change!(application, redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path") + test "#authorize/3 generates grant with redirect uri", %{ + resource_owner: resource_owner, + application: application + } do + QueryHelpers.change!(application, + redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path" + ) - request = Map.merge(@valid_request, %{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) + request = + Map.merge(@valid_request, %{ + "redirect_uri" => "https://example.com/path?param=1", + "state" => 40_612 + }) + + assert {:redirect, redirect_uri} = Authorization.authorize(resource_owner, request, @config) - assert {:redirect, redirect_uri} = Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) access_grant = QueryHelpers.get_latest_inserted(OauthAccessGrant) - assert redirect_uri == "https://example.com/path?code=#{access_grant.token}¶m=1&state=40612" + assert redirect_uri == + "https://example.com/path?code=#{access_grant.token}¶m=1&state=40612" end test "#deny/3 error when no resource owner" do - assert Authorization.deny(nil, @valid_request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.deny(nil, @valid_request, @config) == + {:error, @invalid_request, :bad_request} end test "#deny/3 error when invalid client", %{resource_owner: resource_owner} do request = Map.merge(@valid_request, %{"client_id" => "invalid"}) - assert Authorization.deny(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_client, :unprocessable_entity} + assert Authorization.deny(resource_owner, request, @config) == + {:error, @invalid_client, :unprocessable_entity} end test "#deny/3 error when no client_id", %{resource_owner: resource_owner} do request = Map.delete(@valid_request, "client_id") - assert Authorization.deny(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_request, :bad_request} + assert Authorization.deny(resource_owner, request, @config) == + {:error, @invalid_request, :bad_request} end test "#deny/3", %{resource_owner: resource_owner} do - assert Authorization.deny(resource_owner, @valid_request, otp_app: :ex_oauth2_provider) == {:error, @access_denied, :unauthorized} + assert Authorization.deny(resource_owner, @valid_request, @config) == + {:error, @access_denied, :unauthorized} end test "#deny/3 with redirection uri", %{application: application, resource_owner: resource_owner} do - QueryHelpers.change!(application, redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path") - request = Map.merge(@valid_request, %{"redirect_uri" => "https://example.com/path?param=1", "state" => 40_612}) - - assert Authorization.deny(resource_owner, request, otp_app: :ex_oauth2_provider) == {:redirect, "https://example.com/path?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request.¶m=1&state=40612"} + QueryHelpers.change!(application, + redirect_uri: "#{application.redirect_uri}\nhttps://example.com/path" + ) + + request = + Map.merge(@valid_request, %{ + "redirect_uri" => "https://example.com/path?param=1", + "state" => 40_612 + }) + + assert Authorization.deny(resource_owner, request, @config) == + {:redirect, + "https://example.com/path?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request.¶m=1&state=40612"} end end diff --git a/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs b/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs index a5af7d78..9eacf0f7 100644 --- a/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs +++ b/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs @@ -46,6 +46,10 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do "add :application_id, references(:oauth_applications, on_delete: :nothing, type: binary_id)" refute file =~ ":oauth_device_grants" + + # TODO: this could be improved by testing each table indpendently and + # completely. + assert file =~ "add :is_trusted, :boolean, null: false, default: false" end) end From 745ac8b254e6f379c518b4450f0c6aa526b2ff5a Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Tue, 12 Apr 2022 16:14:40 -0700 Subject: [PATCH 10/11] [MINOR] Make the token authentication function configurable (#5) --- lib/ex_oauth2_provider.ex | 18 ++++++---- .../skip_authorization.ex | 2 +- .../behaviours/token_authentication.ex | 8 +++++ lib/ex_oauth2_provider/config.ex | 13 +++++++ lib/ex_oauth2_provider/features.ex | 2 +- lib/ex_oauth2_provider/plug/verify_header.ex | 23 ++++++------ .../plug/verify_header_test.exs | 35 +++++++++++++++++-- 7 files changed, 81 insertions(+), 20 deletions(-) rename lib/ex_oauth2_provider/{behaviors => behaviours}/skip_authorization.ex (90%) create mode 100644 lib/ex_oauth2_provider/behaviours/token_authentication.ex diff --git a/lib/ex_oauth2_provider.ex b/lib/ex_oauth2_provider.ex index 8d4f40b1..22ee3eca 100644 --- a/lib/ex_oauth2_provider.ex +++ b/lib/ex_oauth2_provider.ex @@ -34,6 +34,8 @@ defmodule ExOauth2Provider do expire. """ + @behaviour ExOauth2Provider.Behaviours.TokenAuthentication + alias ExOauth2Provider.{Config, AccessTokens} @doc """ @@ -48,9 +50,10 @@ defmodule ExOauth2Provider do {:ok, access_token} {:error, reason} """ - @spec authenticate_token(binary(), keyword()) :: {:ok, map()} | {:error, any()} + @impl ExOauth2Provider.Behaviours.TokenAuthentication def authenticate_token(token, config \\ []) def authenticate_token(nil, _config), do: {:error, :token_inaccessible} + def authenticate_token(token, config) do token |> load_access_token(config) @@ -61,37 +64,40 @@ defmodule ExOauth2Provider do defp load_access_token(token, config) do case AccessTokens.get_by_token(token, config) do - nil -> {:error, :token_not_found} + nil -> {:error, :token_not_found} access_token -> {:ok, access_token} end end defp maybe_revoke_previous_refresh_token({:error, error}, _config), do: {:error, error} + defp maybe_revoke_previous_refresh_token({:ok, access_token}, config) do case Config.refresh_token_revoked_on_use?(config) do - true -> revoke_previous_refresh_token(access_token, config) + true -> revoke_previous_refresh_token(access_token, config) false -> {:ok, access_token} end end defp revoke_previous_refresh_token(access_token, config) do case AccessTokens.revoke_previous_refresh_token(access_token, config) do - {:error, _any} -> {:error, :no_association_found} + {:error, _any} -> {:error, :no_association_found} {:ok, _access_token} -> {:ok, access_token} end end defp validate_access_token({:error, error}), do: {:error, error} + defp validate_access_token({:ok, access_token}) do case AccessTokens.is_accessible?(access_token) do - true -> {:ok, access_token} + true -> {:ok, access_token} false -> {:error, :token_inaccessible} end end defp load_resource_owner({:error, error}, _config), do: {:error, error} + defp load_resource_owner({:ok, access_token}, config) do - repo = Config.repo(config) + repo = Config.repo(config) access_token = repo.preload(access_token, :resource_owner) {:ok, access_token} diff --git a/lib/ex_oauth2_provider/behaviors/skip_authorization.ex b/lib/ex_oauth2_provider/behaviours/skip_authorization.ex similarity index 90% rename from lib/ex_oauth2_provider/behaviors/skip_authorization.ex rename to lib/ex_oauth2_provider/behaviours/skip_authorization.ex index e8564e4a..ecc76685 100644 --- a/lib/ex_oauth2_provider/behaviors/skip_authorization.ex +++ b/lib/ex_oauth2_provider/behaviours/skip_authorization.ex @@ -1,4 +1,4 @@ -defmodule ExOauth2Provider.Behaviors.SkipAuthorization do +defmodule ExOauth2Provider.Behaviours.SkipAuthorization do @moduledoc """ Define the rules used to determine if authorization can be skipped. If your app has unique criteria then implement it. diff --git a/lib/ex_oauth2_provider/behaviours/token_authentication.ex b/lib/ex_oauth2_provider/behaviours/token_authentication.ex new file mode 100644 index 00000000..f9a1c903 --- /dev/null +++ b/lib/ex_oauth2_provider/behaviours/token_authentication.ex @@ -0,0 +1,8 @@ +defmodule ExOauth2Provider.Behaviours.TokenAuthentication do + @moduledoc """ + Simple behavior for defining a custom token authentication strategy. + """ + + @callback authenticate_token(token :: binary(), config :: keyword()) :: + {:ok, map()} | {:error, any()} +end diff --git a/lib/ex_oauth2_provider/config.ex b/lib/ex_oauth2_provider/config.ex index 68a14ed6..c54b03a9 100644 --- a/lib/ex_oauth2_provider/config.ex +++ b/lib/ex_oauth2_provider/config.ex @@ -213,6 +213,19 @@ defmodule ExOauth2Provider.Config do ) end + @doc """ + Returns a function that is used to verify that a token string is valid. + This allows you to add in additional functionality like a caching layer or + side effects. + """ + def token_authenticator(config) do + get( + config, + :authenticate_token_with, + &ExOauth2Provider.authenticate_token/2 + ) + end + defp get(config, key, value \\ nil) do otp_app = Keyword.get(config, :otp_app) diff --git a/lib/ex_oauth2_provider/features.ex b/lib/ex_oauth2_provider/features.ex index 7af5844d..7eb228db 100644 --- a/lib/ex_oauth2_provider/features.ex +++ b/lib/ex_oauth2_provider/features.ex @@ -2,7 +2,7 @@ defmodule ExOauth2Provider.Features do @moduledoc """ Determine the status of configurable features. """ - @behaviour ExOauth2Provider.Behaviors.SkipAuthorization + @behaviour ExOauth2Provider.Behaviours.SkipAuthorization def skip_authorization?(_user, _application), do: false end diff --git a/lib/ex_oauth2_provider/plug/verify_header.ex b/lib/ex_oauth2_provider/plug/verify_header.ex index 313ce901..8f266747 100644 --- a/lib/ex_oauth2_provider/plug/verify_header.ex +++ b/lib/ex_oauth2_provider/plug/verify_header.ex @@ -24,6 +24,7 @@ defmodule ExOauth2Provider.Plug.VerifyHeader do """ alias Plug.Conn + alias ExOauth2Provider.Config alias ExOauth2Provider.Plug @doc false @@ -35,8 +36,9 @@ defmodule ExOauth2Provider.Plug.VerifyHeader do end defp maybe_set_realm_option(nil, opts), do: opts + defp maybe_set_realm_option(realm, opts) do - realm = Regex.escape(realm) + realm = Regex.escape(realm) {:ok, realm_regex} = Regex.compile("#{realm}\:?\s+(.*)$", "i") Keyword.put(opts, :realm_regex, realm_regex) @@ -45,12 +47,9 @@ defmodule ExOauth2Provider.Plug.VerifyHeader do @doc false @spec call(Conn.t(), keyword()) :: Conn.t() def call(conn, opts) do - key = Keyword.get(opts, :key, :default) - config = Keyword.take(opts, [:otp_app]) - conn |> fetch_token(opts) - |> verify_token(conn, key, config) + |> verify_token(conn, opts) end defp fetch_token(conn, opts) do @@ -63,20 +62,24 @@ defmodule ExOauth2Provider.Plug.VerifyHeader do defp do_fetch_token(_realm_regex, []), do: nil defp do_fetch_token(nil, [token | _tail]), do: String.trim(token) + defp do_fetch_token(realm_regex, [token | tail]) do trimmed_token = String.trim(token) case Regex.run(realm_regex, trimmed_token) do [_, match] -> String.trim(match) - _ -> do_fetch_token(realm_regex, tail) + _ -> do_fetch_token(realm_regex, tail) end end - defp verify_token(nil, conn, _, _config), do: conn - defp verify_token("", conn, _, _config), do: conn - defp verify_token(token, conn, key, config) do - access_token = ExOauth2Provider.authenticate_token(token, config) + defp verify_token(nil, conn, _), do: conn + defp verify_token("", conn, _), do: conn + + defp verify_token(token, conn, opts) do + key = Keyword.get(opts, :key, :default) + config = Keyword.take(opts, [:authenticate_token_with, :otp_app]) + access_token = Config.token_authenticator(config).(token, config) Plug.set_current_access_token(conn, access_token, key) end end diff --git a/test/ex_oauth2_provider/plug/verify_header_test.exs b/test/ex_oauth2_provider/plug/verify_header_test.exs index daf115f5..e7937242 100644 --- a/test/ex_oauth2_provider/plug/verify_header_test.exs +++ b/test/ex_oauth2_provider/plug/verify_header_test.exs @@ -31,6 +31,7 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do test "at the default location", %{conn: conn, access_token: access_token} do opts = VerifyHeader.init(otp_app: :ex_oauth2_provider) + conn = conn |> Conn.put_req_header("authorization", access_token.token) @@ -42,6 +43,7 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do test "at a specified location", %{conn: conn, access_token: access_token} do opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, key: :secret) + conn = conn |> Conn.put_req_header("authorization", access_token.token) @@ -53,6 +55,7 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do test "with a realm specified", %{conn: conn, access_token: access_token} do opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Bearer") + conn = conn |> Conn.put_req_header("authorization", "Bearer #{access_token.token}") @@ -62,10 +65,14 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do assert Plug.current_access_token(conn) == access_token end - test "with a realm specified and multiple auth headers", %{conn: conn, access_token: access_token} do + test "with a realm specified and multiple auth headers", %{ + conn: conn, + access_token: access_token + } do another_access_token = Fixtures.access_token() opts = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Client") + conn = conn |> Conn.put_req_header("authorization", "Bearer #{access_token.token}") @@ -76,7 +83,10 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do assert Plug.current_access_token(conn) == another_access_token end - test "pulls different tokens into different locations", %{conn: conn, access_token: access_token} do + test "pulls different tokens into different locations", %{ + conn: conn, + access_token: access_token + } do another_access_token = Fixtures.access_token() req_headers = [ @@ -86,6 +96,7 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do opts_1 = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Bearer") opts_2 = VerifyHeader.init(otp_app: :ex_oauth2_provider, realm: "Client", key: :client) + conn = conn |> Map.put(:req_headers, req_headers) @@ -97,5 +108,25 @@ defmodule ExOauth2Provider.Plug.VerifyHeaderTest do assert Plug.authenticated?(conn) assert Plug.current_access_token(conn) == access_token end + + test "with custom authenticator configured", %{conn: conn, access_token: %{token: token}} do + authenticator = fn ^token, [authenticate_token_with: _, otp_app: :ex_oauth2_provider] -> + {:ok, "expected-token"} + end + + opts = + VerifyHeader.init( + authenticate_token_with: authenticator, + otp_app: :ex_oauth2_provider + ) + + conn = + conn + |> Conn.put_req_header("authorization", token) + |> VerifyHeader.call(opts) + + assert Plug.authenticated?(conn) + assert Plug.current_access_token(conn) == "expected-token" + end end end From c99a47cb92f22fb6af70eb302b57b0537a293b37 Mon Sep 17 00:00:00 2001 From: Jeff McKenzie Date: Tue, 28 Jun 2022 11:53:40 -0700 Subject: [PATCH 11/11] [PATCH] Bump Ecto to 3.8.x (#6) --- config/test.exs | 4 +- .../applications/application.ex | 12 ++--- lib/ex_oauth2_provider/schema.ex | 46 ++++++++++++++-- mix.exs | 4 +- mix.lock | 44 ++++++++-------- .../oauth2/token/strategy/revoke_test.exs | 52 ++++++++++++++----- .../ex_oauth2_provider.gen.migration_test.exs | 2 +- 7 files changed, 114 insertions(+), 50 deletions(-) diff --git a/config/test.exs b/config/test.exs index 81ac41ca..6795b490 100644 --- a/config/test.exs +++ b/config/test.exs @@ -20,4 +20,6 @@ config :ex_oauth2_provider, ExOauth2Provider, config :ex_oauth2_provider, Dummy.Repo, database: "ex_oauth2_provider_test", pool: Ecto.Adapters.SQL.Sandbox, - priv: "test/support/priv" + priv: "test/support/priv", + username: "postgres", + password: "postgres" diff --git a/lib/ex_oauth2_provider/applications/application.ex b/lib/ex_oauth2_provider/applications/application.ex index 18fdbf6b..e47b722e 100644 --- a/lib/ex_oauth2_provider/applications/application.ex +++ b/lib/ex_oauth2_provider/applications/application.ex @@ -42,12 +42,12 @@ defmodule ExOauth2Provider.Applications.Application do @doc false def attrs() do [ - {:is_trusted, :boolean, null: false, default: false}, - {:name, :string, null: false}, - {:redirect_uri, :string, null: false}, - {:scopes, :string, null: false, default: ""}, - {:secret, :string, null: false, default: ""}, - {:uid, :string, null: false} + {:is_trusted, :boolean, default: false, null: false}, + {:name, :string}, + {:redirect_uri, :string}, + {:scopes, :string, default: ""}, + {:secret, :string, default: ""}, + {:uid, :string} ] end diff --git a/lib/ex_oauth2_provider/schema.ex b/lib/ex_oauth2_provider/schema.ex index ad9419d1..aacc412c 100644 --- a/lib/ex_oauth2_provider/schema.ex +++ b/lib/ex_oauth2_provider/schema.ex @@ -8,6 +8,23 @@ defmodule ExOauth2Provider.Schema do defmacro __using__(config \\ []) do quote do @config unquote(config) + @ecto_field_options [ + :autogenerate, + :default, + :defaults, + :foreign_key, + :load_in_query, + :on_replace, + :primary_key, + :read_after_writes, + :redact, + :references, + :skip_default_validation, + :source, + :type, + :virtual, + :where + ] end end @@ -19,6 +36,15 @@ defmodule ExOauth2Provider.Schema do field(name, type) {name, type, defaults} -> + # NOTE: Ecto.Schema.field/3 checks the values passed as options. + # :null is not valid so let's only pass what's acceptable so we can + # rely on the attrs defined to generate migrations. + defaults = + Enum.filter( + defaults, + fn {name, _value} -> name in @ecto_field_options end + ) + field(name, type, defaults) end) @@ -44,10 +70,17 @@ defmodule ExOauth2Provider.Schema do @doc false def __assocs_with_queryable__(assocs, config) do Enum.map(assocs, fn - {:belongs_to, name, table} -> {:belongs_to, name, table_to_queryable(config, table)} - {:belongs_to, name, table, defaults} -> {:belongs_to, name, table_to_queryable(config, table), defaults} - {:has_many, name, table} -> {:has_many, name, table_to_queryable(config, table)} - {:has_many, name, table, defaults} -> {:has_many, name, table_to_queryable(config, table), defaults} + {:belongs_to, name, table} -> + {:belongs_to, name, table_to_queryable(config, table)} + + {:belongs_to, name, table, defaults} -> + {:belongs_to, name, table_to_queryable(config, table), defaults} + + {:has_many, name, table} -> + {:has_many, name, table_to_queryable(config, table)} + + {:has_many, name, table, defaults} -> + {:has_many, name, table_to_queryable(config, table), defaults} end) end @@ -67,7 +100,6 @@ defmodule ExOauth2Provider.Schema do defp assocs_match?(:belongs_to, name, {name, %Ecto.Association.BelongsTo{}}), do: true defp assocs_match?(_type, _name, _existing_assoc), do: false - @doc false def __timestamp_for__(struct, column) do type = struct.__schema__(:type, column) @@ -79,15 +111,19 @@ defmodule ExOauth2Provider.Schema do def __timestamp__(:naive_datetime) do %{NaiveDateTime.utc_now() | microsecond: {0, 0}} end + def __timestamp__(:naive_datetime_usec) do NaiveDateTime.utc_now() end + def __timestamp__(:utc_datetime) do DateTime.from_unix!(System.system_time(:second), :second) end + def __timestamp__(:utc_datetime_usec) do DateTime.from_unix!(System.system_time(:microsecond), :microsecond) end + def __timestamp__(type) do type.from_unix!(System.system_time(:microsecond), :microsecond) end diff --git a/mix.exs b/mix.exs index c708b97b..da6ab9cb 100644 --- a/mix.exs +++ b/mix.exs @@ -39,14 +39,14 @@ defmodule ExOauth2Provider.Mixfile do defp deps do [ - {:ecto, "~> 3.0"}, + {:ecto, "~> 3.8"}, {:plug, ">= 1.5.0 and < 2.0.0"}, # Dev and test dependencies {:credo, "~> 1.1.0", only: [:dev, :test]}, {:dialyxir, "~> 1.0.0", only: [:dev, :test], runtime: false}, {:ex_doc, ">= 0.0.0", only: :dev}, - {:ecto_sql, "~> 3.0.0", only: [:dev, :test]}, + {:ecto_sql, "~> 3.8", only: [:dev, :test]}, {:plug_cowboy, "~> 2.0", only: :test}, {:postgrex, "~> 0.14", only: :test} ] diff --git a/mix.lock b/mix.lock index 50c7d346..c1654a95 100644 --- a/mix.lock +++ b/mix.lock @@ -1,26 +1,28 @@ %{ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, - "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, - "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, - "credo": {:hex, :credo, "1.1.2", "02b6422f3e659eb74b05aca3c20c1d8da0119a05ee82577a82e6c2938bf29f81", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd9322e9d391251ca3c4fac10ff0ce86ea4b99b3d9b34526ee2a7baa5928167d"}, - "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "5a0e8c1c722dbcd31c0cbd1906b1d1074c863d335c295e4b994849b65a1fbe47"}, - "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm", "52694ef56e60108e5012f8af9673874c66ed58ac1c4fae9b5b7ded31786663f5"}, + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, + "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, - "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm", "762b999fd414fb41e297944228aa1de2cd4a3876a07f968c8b11d1e9a2190d07"}, - "ecto": {:hex, :ecto, "3.0.9", "f01922a0b91a41d764d4e3a914d7f058d99a03460d3082c61dd2dcadd724c934", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "b5f2b45d3dc6ac4053cc96dec337dc4d28ba4e2d9ad905e2237553fde25d952f"}, - "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5bd47a499d27084afaa3c2154cfedb478ea2fcc926ef59fa515ee089701e390"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, + "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, + "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "93d2fee94d2f88abf507628378371ea5fab08ed03fa59a6daa3d4469d9159ddd"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, - "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, - "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm", "de9825f21c6fd6adfdeae8f9c80dcd88c1e58301f06bf13d659b7e606b88abe0"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, - "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, - "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "0ec1f09319f29b4dfc2d0e08c776834d219faae0da3536f3d9460f6793e6af1f"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm", "63d9f37d319ff331a51f6221310deb5aac8ea3dcf5e0369d689121b5e52f72d4"}, + "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, + "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, } diff --git a/test/ex_oauth2_provider/oauth2/token/strategy/revoke_test.exs b/test/ex_oauth2_provider/oauth2/token/strategy/revoke_test.exs index dc8ad79b..2994db9d 100644 --- a/test/ex_oauth2_provider/oauth2/token/strategy/revoke_test.exs +++ b/test/ex_oauth2_provider/oauth2/token/strategy/revoke_test.exs @@ -5,33 +5,54 @@ defmodule ExOauth2Provider.Token.Strategy.RevokeTest do alias ExOauth2Provider.Test.{Fixtures, QueryHelpers} alias Dummy.OauthAccessTokens.OauthAccessToken - @client_id "Jf5rM8hQBc" - @client_secret "secret" - @invalid_client_error %{error: :invalid_client, - error_description: "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." - } + @client_id "Jf5rM8hQBc" + @client_secret "secret" + @invalid_client_error %{ + error: :invalid_client, + error_description: + "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method." + } setup do user = Fixtures.resource_owner() - application = Fixtures.application(resource_owner: user, uid: @client_id, secret: @client_secret, scopes: "app:read app:write") - access_token = Fixtures.access_token(resource_owner: user, application: application, use_refresh_token: true, scopes: "app:read") - valid_request = %{"client_id" => @client_id, - "client_secret" => @client_secret, - "token" => access_token.token} + application = + Fixtures.application( + resource_owner: user, + uid: @client_id, + secret: @client_secret, + scopes: "app:read app:write" + ) + + access_token = + Fixtures.access_token( + resource_owner: user, + application: application, + use_refresh_token: true, + scopes: "app:read" + ) + + valid_request = %{ + "client_id" => @client_id, + "client_secret" => @client_secret, + "token" => access_token.token + } + {:ok, %{access_token: access_token, valid_request: valid_request}} end test "#revoke/2 error when invalid client", %{valid_request: valid_request} do params = Map.merge(valid_request, %{"client_id" => "invalid"}) - assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} + assert Token.revoke(params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_client_error, :unprocessable_entity} end test "#revoke/2 error when invalid secret", %{valid_request: valid_request} do params = Map.merge(valid_request, %{"client_secret" => "invalid"}) - assert Token.revoke(params, otp_app: :ex_oauth2_provider) == {:error, @invalid_client_error, :unprocessable_entity} + assert Token.revoke(params, otp_app: :ex_oauth2_provider) == + {:error, @invalid_client_error, :unprocessable_entity} end test "#revoke/2 when missing token", %{valid_request: valid_request} do @@ -48,8 +69,11 @@ defmodule ExOauth2Provider.Token.Strategy.RevokeTest do refute AccessTokens.is_revoked?(QueryHelpers.get_latest_inserted(OauthAccessToken)) end - test "#revoke/2 when access token owned by another client", %{valid_request: valid_request, access_token: access_token} do - new_application = Fixtures.application(uid: "new_app", client_secret: "new") + test "#revoke/2 when access token owned by another client", %{ + valid_request: valid_request, + access_token: access_token + } do + new_application = Fixtures.application(uid: "new_app", secret: "new") QueryHelpers.change!(access_token, application_id: new_application.id) assert Token.revoke(valid_request, otp_app: :ex_oauth2_provider) == {:ok, %{}} diff --git a/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs b/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs index 9eacf0f7..dad1bec2 100644 --- a/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs +++ b/test/mix/tasks/ex_oauth2_provider.gen.migration_test.exs @@ -49,7 +49,7 @@ defmodule Mix.Tasks.ExOauth2Provider.Gen.MigrationTest do # TODO: this could be improved by testing each table indpendently and # completely. - assert file =~ "add :is_trusted, :boolean, null: false, default: false" + assert file =~ "add :is_trusted, :boolean, default: false, null: false" end) end