diff --git a/.gitignore b/.gitignore index ccbc8af5..6a4c4c69 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ erl_crash.dump /tmp .DS_Store /.elixir_ls +.tool-versions +/priv/pg_data diff --git a/README.md b/README.md index c47716c4..3587c59e 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,21 @@ You'll need to create the migration file and schema modules with the argument `- mix ex_oauth2_provider.install --binary-id ``` +## Development + +Starting postgrest for test +( one will need to modify the pg_hba.conf after booting postges for the first time) +( notes are in the script below ) +```bash +./postgres.zsh +``` + +Testing binary_id +```bash +mix clean && UUID=1 mix test +``` + + ## Acknowledgement This library was made thanks to [doorkeeper](https://github.com/doorkeeper-gem/doorkeeper), [guardian](https://github.com/ueberauth/guardian) and [authable](https://github.com/mustafaturan/authable), that gave the conceptual building blocks. diff --git a/lib/ex_oauth2_provider/config.ex b/lib/ex_oauth2_provider/config.ex index cfc2c0c1..9d1f241c 100644 --- a/lib/ex_oauth2_provider/config.ex +++ b/lib/ex_oauth2_provider/config.ex @@ -100,6 +100,11 @@ defmodule ExOauth2Provider.Config do def use_refresh_token?(config), do: get(config, :use_refresh_token, false) + # Require PKCE for code grant (disabled by default) recommend enabling + @spec use_pkce?(keyword()) :: boolean() + def use_pkce?(config), + do: get(config, :use_pkce, false) + # Password auth method to use. Disabled by default. When set, it'll enable # password auth strategy. Set config as: # `password_auth: {MyModule, :my_auth_method}` diff --git a/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex b/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex index 40b3cb02..58cb41ff 100644 --- a/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex +++ b/lib/ex_oauth2_provider/oauth2/authorization/strategy/code.ex @@ -184,12 +184,22 @@ defmodule ExOauth2Provider.Authorization.Code do defp validate_request({:error, params}, _config), do: {:error, params} defp validate_request({:ok, params}, config) do + # TODO smells like railway pattern here - meta opportunity {:ok, params} + |> validate_pkce_params(Config.use_pkce?(config)) |> validate_resource_owner() |> validate_redirect_uri(config) |> validate_scopes(config) end + + defp validate_pkce_params({:error, params} , _), do: {:error, params} + defp validate_pkce_params({:ok, params}, false), do: {:ok, params} + defp validate_pkce_params({:ok, %{request: %{"code_challenge" => _code_challenge, "code_challenge_method" => _code_challenge_method}}} = input, true), do: input + defp validate_pkce_params({:ok, params}, true), do: Error.add_error({:ok, params}, Error.invalid_pkce_auth()) + + + defp validate_resource_owner({:error, params}), do: {:error, params} defp validate_resource_owner({:ok, %{resource_owner: resource_owner} = params}) do case resource_owner do %{__struct__: _} -> {:ok, params} diff --git a/lib/ex_oauth2_provider/oauth2/utils/error.ex b/lib/ex_oauth2_provider/oauth2/utils/error.ex index 02bb83f7..626e5943 100644 --- a/lib/ex_oauth2_provider/oauth2/utils/error.ex +++ b/lib/ex_oauth2_provider/oauth2/utils/error.ex @@ -21,6 +21,14 @@ defmodule ExOauth2Provider.Utils.Error do {:error, %{error: :invalid_request, error_description: msg}, :bad_request} end + @doc false + @spec invalid_pkce_auth() :: {:error, map(), atom()} + def invalid_pkce_auth do + msg = "PKCE enabled: The request is missing required parameters" + {:error, %{error: :invalid_pkce, error_description: msg}, :bad_request} + end + + @doc false @spec invalid_client() :: {:error, map(), atom()} def invalid_client do diff --git a/postgres.zsh b/postgres.zsh new file mode 100755 index 00000000..e307b9b3 --- /dev/null +++ b/postgres.zsh @@ -0,0 +1,10 @@ +#!/usr/bin/env zsh +SCRIPT_DIR=$0:a:h +PG_NAME=postgres-oauth2-provider +echo " NOTE: For first time use i manually hack \"host all all all trust\" into priv/pg_data/pg_hba.conf" +echo " after the director has been initialized" +echo " then I stop this script and restart it " +docker stop $PG_NAME +docker run --rm --name $PG_NAME -e POSTGRES_USER=$USER -e POSTGRES_PASSWORD=secret -p 5432:5432 -v $SCRIPT_DIR/priv/pg_data:/var/lib/postgresql/data postgres + + diff --git a/test/ex_oauth2_provider/config_test.exs b/test/ex_oauth2_provider/config_test.exs index 94521b59..cf5b36b3 100644 --- a/test/ex_oauth2_provider/config_test.exs +++ b/test/ex_oauth2_provider/config_test.exs @@ -13,9 +13,9 @@ defmodule ExOauth2Provider.ConfigTest do assert Config.repo(otp_app: :my_app) == Dummy.Repo Application.delete_env(:ex_oauth2_provider, ExOauth2Provider) - Application.put_env(:my_app, ExOauth2Provider, repo: Dummy.Repo) + Application.put_env(:my_app, ExOauth2Provider, repo: :different_repo) - assert Config.repo(otp_app: :my_app) == Dummy.Repo + assert Config.repo(otp_app: :my_app) == :different_repo Application.delete_env(:my_app, ExOauth2Provider) 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..bca02122 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,30 @@ defmodule ExOauth2Provider.Authorization.CodeTest do use ExOauth2Provider.TestCase - alias ExOauth2Provider.{Authorization, Config, Scopes} + alias ExOauth2Provider.{Authorization, Config, Scopes, Utils} 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." - } + @code_challenge_method "S256" + @custom_native_redirect "app://callback" + @client_id "Jf5rM8hQBc" + @valid_request %{"client_id" => @client_id, "response_type" => "code"} + @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." + } + @pkce_enabled_auth_missing_fields elem(Utils.Error.invalid_pkce_auth(), 1) setup do resource_owner = Fixtures.resource_owner() @@ -83,6 +86,12 @@ defmodule ExOauth2Provider.Authorization.CodeTest do %{resource_owner: resource_owner, application: application} end + test "with no scope", %{resource_owner: resource_owner, application: application} do + request = Map.delete(@valid_request, "scope") + + assert Authorization.preauthorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:ok, application, []} + end + test "with limited server scope", %{resource_owner: resource_owner, application: application} do request = Map.merge(@valid_request, %{"scope" => "read"}) @@ -127,6 +136,63 @@ defmodule ExOauth2Provider.Authorization.CodeTest do assert Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) == {:error, @invalid_scope, :unprocessable_entity} end + describe "#authorize/3 custom native_redirect" do + setup %{resource_owner: resource_owner, application: application} do + config = Application.get_env(:ex_oauth2_provider, ExOauth2Provider) + config = [{:native_redirect_uri, @custom_native_redirect} | config] + config = Application.put_env(:ex_oauth2_provider, ExOauth2Provider, config) + + application = QueryHelpers.change!(application, scopes: "") + application = QueryHelpers.change!(application, redirect_uri: @custom_native_redirect) + + verifier = :crypto.strong_rand_bytes(128) + #code_verifier= Base.url_encode64(verifier, padding: false) + + %{resource_owner: resource_owner, application: application, verifier: verifier} + end + test "generates a grant", %{verifier: verifier, resource_owner: resource_owner} do + + assert {:native_redirect, %{code: code}} = Authorization.authorize(resource_owner, @valid_request, otp_app: :ex_oauth2_provider) + access_grant = Repo.get_by(OauthAccessGrant, token: code) + assert access_grant.resource_owner_id == resource_owner.id + assert access_grant.redirect_uri == "app://callback" + end + end + + describe "#authorize/3 with PKCE enabled" do + setup %{resource_owner: resource_owner, application: application} do + config = Application.get_env(:ex_oauth2_provider, ExOauth2Provider) + config = [{:use_pkce, true} | config] + config = Application.put_env(:ex_oauth2_provider, ExOauth2Provider, config) + + application = QueryHelpers.change!(application, scopes: "") + + verifier = :crypto.strong_rand_bytes(128) + #code_verifier= Base.url_encode64(verifier, padding: false) + + %{resource_owner: resource_owner, application: application, verifier: verifier} + end + + test "returns an error without code_challenge", %{verifier: verifier, resource_owner: resource_owner} do + # a valid request without a code_challenge is invalid with pkce on + missing_code_challenge_request = Map.delete(@valid_request, "code_challenge") + assert Authorization.authorize(resource_owner, missing_code_challenge_request, otp_app: :ex_oauth2_provider) == {:error, @pkce_enabled_auth_missing_fields, :bad_request} + end + + test "generates a grant with a code_verifier", %{verifier: verifier, resource_owner: resource_owner} do + code_challenge = Base.url_encode64(:crypto.hash(:sha256, verifier)) + request = Map.merge(@valid_request, %{"code_challenge" => code_challenge, "code_challenge_method" => @code_challenge_method }) + + assert {:native_redirect, %{code: code}} = Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) + access_grant = Repo.get_by(OauthAccessGrant, token: code) + assert access_grant.resource_owner_id == resource_owner.id + # todo + # assert access_grant.code_challege_method == @code_challenge_method + # assert access_grant.code_challege == code_chalenge_method + end + end + + describe "#authorize/3 when application has no scope" do setup %{resource_owner: resource_owner, application: application} do application = QueryHelpers.change!(application, scopes: "") @@ -134,12 +200,20 @@ defmodule ExOauth2Provider.Authorization.CodeTest do %{resource_owner: resource_owner, application: application} end + test "generates grant with no scope passed", %{resource_owner: resource_owner} do + request = Map.delete(@valid_request, "scope") + assert {:native_redirect, %{code: code}} = Authorization.authorize(resource_owner, request, otp_app: :ex_oauth2_provider) + + access_grant = Repo.get_by(OauthAccessGrant, token: code) + assert access_grant.resource_owner_id == resource_owner.id + end + 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} end - test "generates grant", %{resource_owner: resource_owner} do + test "generates grant with public scope", %{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) @@ -160,7 +234,7 @@ defmodule ExOauth2Provider.Authorization.CodeTest do 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.scopes == @valid_request["scope"] + assert access_grant.scopes == "" end test "#authorize/3 generates grant with redirect uri", %{resource_owner: resource_owner, application: application} do