From 8d9f70f18aac40dd9a1cf40da36209d20426ec5d Mon Sep 17 00:00:00 2001 From: Ali Shirvani Date: Wed, 26 Jun 2024 17:19:19 +0330 Subject: [PATCH 1/4] Add require authorization plug --- README.md | 6 ++ lib/oidcc/plug/extract_authorization.ex | 3 +- lib/oidcc/plug/require_authorization.ex | 132 ++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 lib/oidcc/plug/require_authorization.ex diff --git a/README.md b/README.md index 1ff72c1..736c384 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,12 @@ defmodule SampleAppWeb.Endpoint do client_id: @client_id, client_secret: @client_secret + # OR: Require a valid JWT Token + plug Oidcc.Plug.RequireAuthorization, + provider: SampleApp.GoogleOpenIdConfigurationProvider, + client_id: @client_id, + client_secret: @client_secret + plug SampleAppWeb.Router end ``` diff --git a/lib/oidcc/plug/extract_authorization.ex b/lib/oidcc/plug/extract_authorization.ex index b792bf9..048bde7 100644 --- a/lib/oidcc/plug/extract_authorization.ex +++ b/lib/oidcc/plug/extract_authorization.ex @@ -3,7 +3,7 @@ defmodule Oidcc.Plug.ExtractAuthorization do Extract `authorization` request header This module should be used together with `Oidcc.Plug.IntrospectToken`, - `Oidcc.Plug.LoadUserinfo` or `Oidcc.Plug.ValidateJwtToken`. + `Oidcc.Plug.LoadUserinfo` or `Oidcc.Plug.ValidateJwtToken` or `Oidcc.Plug.RequireAuthorization`. ```elixir defmodule SampleAppWeb.Endpoint do @@ -16,6 +16,7 @@ defmodule Oidcc.Plug.ExtractAuthorization do plug Oidcc.Plug.IntrospectToken, [...] # Check Token via Introspection plug Oidcc.Plug.LoadUserinfo, [...] # Check Token via Userinfo plug Oidcc.Plug.ValidateJwtToken, [...] # Check Token via JWT validation + plug Oidcc.Plug.RequireAuthorization, [...] # Require valid JWT Token plug SampleAppWeb.Router end diff --git a/lib/oidcc/plug/require_authorization.ex b/lib/oidcc/plug/require_authorization.ex new file mode 100644 index 0000000..106b1c8 --- /dev/null +++ b/lib/oidcc/plug/require_authorization.ex @@ -0,0 +1,132 @@ +defmodule Oidcc.Plug.RequireAuthorization do + @moduledoc """ + Validate extracted authorization token by validating it as a JWT token. + + This module should be used together with `Oidcc.Plug.ExtractAuthorization`. + + ```elixir + defmodule SampleAppWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :sample_app + + # ... + + plug Oidcc.Plug.ExtractAuthorization + + plug Oidcc.Plug.RequireAuthorization, + provider: SampleApp.GoogleOpenIdConfigurationProvider, + client_id: Application.compile_env!(:sample_app, [Oidcc.Plug.RequireAuthorization, :client_id]), + client_secret: Application.compile_env!(:sample_app, [Oidcc.Plug.RequireAuthorization, :client_secret]) + + plug SampleAppWeb.Router + end + ``` + """ + @moduledoc since: "0.1.0" + + @behaviour Plug + + import Plug.Conn, only: [put_private: 3, halt: 1, send_resp: 3, put_resp_header: 3] + + import Oidcc.Plug.Config, only: [evaluate_config: 1] + + alias Oidcc.Plug.ExtractAuthorization + + @typedoc """ + Plug Configuration Options + + ## Options + + * `provider` - name of the `Oidcc.ProviderConfiguration.Worker` + * `client_id` - OAuth Client ID to use for the token validation + * `client_secret` - OAuth Client Secret to use for the token validation + * `send_inactive_token_response` - Customize Error Response for inactive token + * `send_missing_token_response` - Customize Error Response for missing token + """ + @typedoc since: "0.1.0" + @type opts :: [ + provider: GenServer.name(), + client_id: String.t() | (-> String.t()), + client_secret: String.t() | (-> String.t()), + send_inactive_token_response: (conn :: Plug.Conn.t() -> Plug.Conn.t()), + send_missing_token_response: (conn :: Plug.Conn.t() -> Plug.Conn.t()) + ] + + defmodule Error do + @moduledoc """ + Validation Failed + + Check the `reason` field for ther exact reason + """ + @moduledoc since: "0.1.0" + + defexception [:reason] + + @impl Exception + def message(_exception), do: "Validation Failed" + end + + @impl Plug + def init(opts), + do: + Keyword.validate!(opts, [ + :provider, + :client_id, + :client_secret, + send_inactive_token_response: &__MODULE__.send_inactive_token_response/1, + send_missing_token_response: &__MODULE__.send_missing_token_response/1 + ]) + + @impl Plug + def call(%Plug.Conn{private: %{ExtractAuthorization => nil}} = conn, opts) do + send_missing_token_response = Keyword.fetch!(opts, :send_missing_token_response) + + conn + |> put_private(__MODULE__, nil) + |> send_missing_token_response.() + end + + def call(%Plug.Conn{private: %{ExtractAuthorization => access_token}} = conn, opts) do + provider = Keyword.fetch!(opts, :provider) + client_id = opts |> Keyword.fetch!(:client_id) |> evaluate_config() + client_secret = opts |> Keyword.fetch!(:client_secret) |> evaluate_config() + + send_inactive_token_response = Keyword.fetch!(opts, :send_inactive_token_response) + + with {:ok, client_context} <- + Oidcc.ClientContext.from_configuration_worker(provider, client_id, client_secret), + {:ok, claims} <- Oidcc.Token.validate_id_token(access_token, client_context, :any) do + put_private(conn, __MODULE__, claims) + else + {:error, :token_expired} -> + conn + |> put_private(__MODULE__, nil) + |> send_inactive_token_response.() + + {:error, reason} -> + raise Error, reason: reason + end + end + + def call(%Plug.Conn{} = _conn, _opts) do + raise """ + The plug Oidcc.Plug.ExtractAuthorization must be run before this plug + """ + end + + @doc false + @spec send_inactive_token_response(conn :: Plug.Conn.t()) :: Plug.Conn.t() + def send_inactive_token_response(conn) do + conn + |> halt() + |> send_resp(:unauthorized, "The provided token is inactive") + end + + @doc false + @spec send_missing_token_response(conn :: Plug.Conn.t()) :: Plug.Conn.t() + def send_missing_token_response(conn) do + conn + |> halt() + |> put_resp_header("WWW-Authenticate", "Bearer") + |> send_resp(:unauthorized, "The authorization token is required") + end +end From 876406d07e813d22583fb5ac8d03731a67bddea5 Mon Sep 17 00:00:00 2001 From: Ali Shirvani Date: Wed, 26 Jun 2024 18:15:08 +0330 Subject: [PATCH 2/4] Remove duplicate codes from RequireAuthorization plug --- lib/oidcc/plug/extract_authorization.ex | 4 +- lib/oidcc/plug/require_authorization.ex | 77 +++---------------------- 2 files changed, 11 insertions(+), 70 deletions(-) diff --git a/lib/oidcc/plug/extract_authorization.ex b/lib/oidcc/plug/extract_authorization.ex index 048bde7..e84adab 100644 --- a/lib/oidcc/plug/extract_authorization.ex +++ b/lib/oidcc/plug/extract_authorization.ex @@ -3,7 +3,7 @@ defmodule Oidcc.Plug.ExtractAuthorization do Extract `authorization` request header This module should be used together with `Oidcc.Plug.IntrospectToken`, - `Oidcc.Plug.LoadUserinfo` or `Oidcc.Plug.ValidateJwtToken` or `Oidcc.Plug.RequireAuthorization`. + `Oidcc.Plug.LoadUserinfo` or `Oidcc.Plug.ValidateJwtToken`. ```elixir defmodule SampleAppWeb.Endpoint do @@ -13,10 +13,10 @@ defmodule Oidcc.Plug.ExtractAuthorization do plug Oidcc.Plug.ExtractAuthorization + plug Oidcc.Plug.RequireAuthorization, [...] # Ensure Authorization Token provided plug Oidcc.Plug.IntrospectToken, [...] # Check Token via Introspection plug Oidcc.Plug.LoadUserinfo, [...] # Check Token via Userinfo plug Oidcc.Plug.ValidateJwtToken, [...] # Check Token via JWT validation - plug Oidcc.Plug.RequireAuthorization, [...] # Require valid JWT Token plug SampleAppWeb.Router end diff --git a/lib/oidcc/plug/require_authorization.ex b/lib/oidcc/plug/require_authorization.ex index 106b1c8..0249bd1 100644 --- a/lib/oidcc/plug/require_authorization.ex +++ b/lib/oidcc/plug/require_authorization.ex @@ -1,6 +1,6 @@ defmodule Oidcc.Plug.RequireAuthorization do @moduledoc """ - Validate extracted authorization token by validating it as a JWT token. + Ensure authorization token provided. This module should be used together with `Oidcc.Plug.ExtractAuthorization`. @@ -12,10 +12,9 @@ defmodule Oidcc.Plug.RequireAuthorization do plug Oidcc.Plug.ExtractAuthorization - plug Oidcc.Plug.RequireAuthorization, - provider: SampleApp.GoogleOpenIdConfigurationProvider, - client_id: Application.compile_env!(:sample_app, [Oidcc.Plug.RequireAuthorization, :client_id]), - client_secret: Application.compile_env!(:sample_app, [Oidcc.Plug.RequireAuthorization, :client_secret]) + plug Oidcc.Plug.RequireAuthorization + + # Check Token with `Oidcc.Plug.IntrospectToken`, `Oidcc.Plug.LoadUserinfo` or `Oidcc.Plug.ValidateJwtToken` plug SampleAppWeb.Router end @@ -25,9 +24,7 @@ defmodule Oidcc.Plug.RequireAuthorization do @behaviour Plug - import Plug.Conn, only: [put_private: 3, halt: 1, send_resp: 3, put_resp_header: 3] - - import Oidcc.Plug.Config, only: [evaluate_config: 1] + import Plug.Conn, only: [halt: 1, send_resp: 3, put_resp_header: 3] alias Oidcc.Plug.ExtractAuthorization @@ -36,76 +33,28 @@ defmodule Oidcc.Plug.RequireAuthorization do ## Options - * `provider` - name of the `Oidcc.ProviderConfiguration.Worker` - * `client_id` - OAuth Client ID to use for the token validation - * `client_secret` - OAuth Client Secret to use for the token validation - * `send_inactive_token_response` - Customize Error Response for inactive token * `send_missing_token_response` - Customize Error Response for missing token """ @typedoc since: "0.1.0" @type opts :: [ - provider: GenServer.name(), - client_id: String.t() | (-> String.t()), - client_secret: String.t() | (-> String.t()), - send_inactive_token_response: (conn :: Plug.Conn.t() -> Plug.Conn.t()), send_missing_token_response: (conn :: Plug.Conn.t() -> Plug.Conn.t()) ] - defmodule Error do - @moduledoc """ - Validation Failed - - Check the `reason` field for ther exact reason - """ - @moduledoc since: "0.1.0" - - defexception [:reason] - - @impl Exception - def message(_exception), do: "Validation Failed" - end - @impl Plug def init(opts), do: - Keyword.validate!(opts, [ - :provider, - :client_id, - :client_secret, - send_inactive_token_response: &__MODULE__.send_inactive_token_response/1, + Keyword.validate!(opts, send_missing_token_response: &__MODULE__.send_missing_token_response/1 - ]) + ) @impl Plug def call(%Plug.Conn{private: %{ExtractAuthorization => nil}} = conn, opts) do send_missing_token_response = Keyword.fetch!(opts, :send_missing_token_response) - conn - |> put_private(__MODULE__, nil) - |> send_missing_token_response.() + send_missing_token_response.(conn) end - def call(%Plug.Conn{private: %{ExtractAuthorization => access_token}} = conn, opts) do - provider = Keyword.fetch!(opts, :provider) - client_id = opts |> Keyword.fetch!(:client_id) |> evaluate_config() - client_secret = opts |> Keyword.fetch!(:client_secret) |> evaluate_config() - - send_inactive_token_response = Keyword.fetch!(opts, :send_inactive_token_response) - - with {:ok, client_context} <- - Oidcc.ClientContext.from_configuration_worker(provider, client_id, client_secret), - {:ok, claims} <- Oidcc.Token.validate_id_token(access_token, client_context, :any) do - put_private(conn, __MODULE__, claims) - else - {:error, :token_expired} -> - conn - |> put_private(__MODULE__, nil) - |> send_inactive_token_response.() - - {:error, reason} -> - raise Error, reason: reason - end - end + def call(%Plug.Conn{private: %{ExtractAuthorization => access_token}} = conn, opts), do: conn def call(%Plug.Conn{} = _conn, _opts) do raise """ @@ -113,14 +62,6 @@ defmodule Oidcc.Plug.RequireAuthorization do """ end - @doc false - @spec send_inactive_token_response(conn :: Plug.Conn.t()) :: Plug.Conn.t() - def send_inactive_token_response(conn) do - conn - |> halt() - |> send_resp(:unauthorized, "The provided token is inactive") - end - @doc false @spec send_missing_token_response(conn :: Plug.Conn.t()) :: Plug.Conn.t() def send_missing_token_response(conn) do From 764c07d8ea04d1a3f259fef3077b2cebdfdf33a5 Mon Sep 17 00:00:00 2001 From: Ali Shirvani Date: Wed, 26 Jun 2024 18:17:13 +0330 Subject: [PATCH 3/4] Update README --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 736c384..d2532a7 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ defmodule SampleAppWeb.Endpoint do @client_id Application.compile_env!(:sample_app, [:openid_credentials, :client_id]) @client_secret Application.compile_env!(:sample_app, [:openid_credentials, :client_secret]) + # Ensure Authorization Token provided + plug Oidcc.Plug.RequireAuthorization + # Check Token via Introspection plug Oidcc.Plug.IntrospectToken, provider: SampleApp.GoogleOpenIdConfigurationProvider, @@ -200,12 +203,6 @@ defmodule SampleAppWeb.Endpoint do client_id: @client_id, client_secret: @client_secret - # OR: Require a valid JWT Token - plug Oidcc.Plug.RequireAuthorization, - provider: SampleApp.GoogleOpenIdConfigurationProvider, - client_id: @client_id, - client_secret: @client_secret - plug SampleAppWeb.Router end ``` From 34758b50564a869cbd95ca14abf6c2d0ab69b046 Mon Sep 17 00:00:00 2001 From: Ali Shirvani Date: Wed, 26 Jun 2024 21:02:15 +0330 Subject: [PATCH 4/4] Test added for require authorization plug --- lib/oidcc/plug/require_authorization.ex | 4 +- test/oidcc/plug/require_authorization.exs | 51 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/oidcc/plug/require_authorization.exs diff --git a/lib/oidcc/plug/require_authorization.ex b/lib/oidcc/plug/require_authorization.ex index 0249bd1..eafe66d 100644 --- a/lib/oidcc/plug/require_authorization.ex +++ b/lib/oidcc/plug/require_authorization.ex @@ -54,7 +54,7 @@ defmodule Oidcc.Plug.RequireAuthorization do send_missing_token_response.(conn) end - def call(%Plug.Conn{private: %{ExtractAuthorization => access_token}} = conn, opts), do: conn + def call(%Plug.Conn{private: %{ExtractAuthorization => _access_token}} = conn, _opts), do: conn def call(%Plug.Conn{} = _conn, _opts) do raise """ @@ -67,7 +67,7 @@ defmodule Oidcc.Plug.RequireAuthorization do def send_missing_token_response(conn) do conn |> halt() - |> put_resp_header("WWW-Authenticate", "Bearer") + |> put_resp_header("www-authenticate", "Bearer") |> send_resp(:unauthorized, "The authorization token is required") end end diff --git a/test/oidcc/plug/require_authorization.exs b/test/oidcc/plug/require_authorization.exs new file mode 100644 index 0000000..4efe665 --- /dev/null +++ b/test/oidcc/plug/require_authorization.exs @@ -0,0 +1,51 @@ +defmodule Oidcc.Plug.RequireAuthorizationTest do + use ExUnit.Case, async: false + use Plug.Test + + alias Oidcc.Plug.ExtractAuthorization + alias Oidcc.Plug.RequireAuthorization + + doctest RequireAuthorization + + describe inspect(&RequireAuthorization.call/2) do + test "errors without ExtractAuthorization" do + opts = + RequireAuthorization.init([]) + + assert_raise RuntimeError, fn -> + "get" + |> conn("/", "") + |> RequireAuthorization.call(opts) + end + end + + test "send error response if no token provided" do + opts = + RequireAuthorization.init([]) + + assert %{ + halted: true, + status: 401, + resp_headers: [ + {"cache-control", "max-age=0, private, must-revalidate"}, + {"www-authenticate", "Bearer"} + ] + } = + "get" + |> conn("/", "") + |> put_private(ExtractAuthorization, nil) + |> RequireAuthorization.call(opts) + end + + test "pass if token provided" do + opts = + RequireAuthorization.init([]) + + assert %{halted: false} = + "get" + |> conn("/", "") + |> put_private(ExtractAuthorization, "some_access_token") + |> RequireAuthorization.call(opts) + end + end +end