From 041bb9a149860fac1706ab8549fb25ff6f00c0f5 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Fri, 7 Jun 2024 18:36:59 +0200 Subject: [PATCH 01/18] [] added zitadel strategy + PKCE --- lib/assent/strategies/zitadel.ex | 68 ++++++++++++++++++ test/assent/strategies/zitadel_test.exs | 94 +++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 lib/assent/strategies/zitadel.ex create mode 100644 test/assent/strategies/zitadel_test.exs diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex new file mode 100644 index 0000000..bf2ce14 --- /dev/null +++ b/lib/assent/strategies/zitadel.ex @@ -0,0 +1,68 @@ +defmodule Assent.Strategy.Zitadel do + @moduledoc """ + Zitadel Sign In OIDC strategy. + + ## Usage + + ### Required configuration parameters + config = [ + base_url: "" + client_id: "REPLACE_WITH_CLIENT_ID", + redirect_uri: "http://localhost:4000/auth/callback", + response_type: one of code, id_token token, id_token, + scope: openid is required other options [email, profile] + code_challenge: The SHA-256 value of the generated code_verifier, + code_challenge_method: "S256" + ] + """ + use Assent.Strategy.OIDC.Base + + alias Assent.{Config, Strategy.OIDC.Base} + + @impl true + def default_config(config) do + {:ok, base_url} = Config.fetch(config, :base_url) + + [ + base_url: base_url, + openid_configuration: %{ + "issuer" => "https://zitadel.cloud", + "authorization_endpoint" => base_url <> "/oauth/v2/authorize", + "token_endpoint" => base_url <> "/oauth/v2/token", + "jwks_uri" => base_url <> "/oauth/v2/keys", + "token_endpoint_auth_methods_supported" => ["client_secret_post"] + }, + authorization_params: [scope: "email", response_type: "code"], + client_authentication_method: "client_secret_post", + openid_default_scope: "openid" + ] + end + + @doc false + @impl true + def callback(config, params) do + config + |> Base.callback(params, __MODULE__) + end + + # @impl true + # def fetch_user(config, token) do + # with {:ok, user} <- OIDC.fetch_user(config, token), + # {:ok, user_info} <- Config.fetch(config, :user) do + # {:ok, Map.merge(user, user_info)} + # end + # end + + # @impl true + # def normalize(_config, user) do + # {:ok, + # %{ + # "sub" => user["sub"], + # "email" => user["email"], + # "email_verified" => true, + # "given_name" => Map.get(user, "name", %{})["firstName"], + # "family_name" => Map.get(user, "name", %{})["lastName"], + # "roles" => Map.get(user["profile"], "urn:zitadel:iam:org:project:roles") + # }} + # end +end diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs new file mode 100644 index 0000000..03892ad --- /dev/null +++ b/test/assent/strategies/zitadel_test.exs @@ -0,0 +1,94 @@ +defmodule Assent.Strategy.ZitadelTest do + use Assent.Test.OIDCTestCase + + alias Assent.Strategy.Zitadel + + @client_id "nameofproject@3425235252" + @id_token_claims %{ + "iss" => "https://zitadel.cloud", + "sub" => "001473.fe6f6f83bf4b8e4590aacbabdcb8598bd0.2039", + "aud" => @client_id, + "exp" => :os.system_time(:second) + 5 * 60, + "iat" => :os.system_time(:second), + "email" => "john.doe@example.com", + "nonce" => "123523" + } + @user %{ + "email" => "john.doe@example.com", + "sub" => "001473.fe6f6f83bf4b8e4590aacbabdcb8598bd0.2039" + } + + setup %{config: config, callback_params: callback_params} do + openid_config = %{ + "issuer" => "https://zitadel.cloud", + "authorization_endpoint" => TestServer.url("/oauth/v2/authorize"), + "token_endpoint" => TestServer.url("/oauth/v2/token"), + "userinfo_endpoint" => TestServer.url("/userinfo"), + "jwks_uri" => TestServer.url("/jwks_uri.json"), + "token_endpoint_auth_methods_supported" => ["client_secret_post"] + } + + openid_configuration = + Map.merge(Map.delete(config[:openid_configuration], "issuer"), openid_config) + + config = Keyword.put(config, :openid_configuration, openid_configuration) + + callback_params = + Map.merge(callback_params, %{"nonce" => "123523", "code_verifier" => "456856"}) + + {:ok, config: config, callback_params: callback_params} + end + + test "authorize_url/2", %{config: config} do + assert {:ok, %{url: url}} = Zitadel.authorize_url(config) + assert url =~ "/oauth/v2/authorize?client_id=id" + assert url =~ "scope=openid+email" + assert url =~ "response_type=code" + end + + test "authorize_url/2 with PKCE", %{config: config} do + assert {:ok, %{url: url}} = Zitadel.authorize_url(config ++ [code_verifier: true, nonce: true]) + assert url =~ "/oauth/v2/authorize?client_id=id" + assert url =~ "scope=openid+email" + assert url =~ "response_type=code" + assert url =~ "code_challenge=" + assert url =~ "nonce=" + assert url =~ "state=" + assert url =~ "code_challenge_method=S256" + assert not String.match?(url, ~r/code_verifier/) + end + + test "callback/2", %{config: config, callback_params: params} do + openid_config = + config[:openid_configuration] + |> Map.put( + "issuer", + "https://zitadel.cloud" + ) + |> Map.put("token_endpoint_auth_methods_supported", [ + "client_secret_post", + "private_key_jwt", + "client_secret_basic" + ]) + + session_params = Map.put(config[:session_params], :nonce, "123523") + session_params = Map.put(session_params, :code_verifier, "456856") + + config = + Keyword.merge(config, + openid_configuration: openid_config, + client_id: @client_id, + session_params: session_params + ) + + [key | _rest] = expect_oidc_jwks_uri_request() + + expect_oidc_access_token_request( + id_token_opts: [claims: @id_token_claims, kid: key["kid"]], + uri: "/oauth/v2/token" + ) + + assert {:ok, %{user: user}} = Zitadel.callback(config, params) + assert user == @user + end +end From 4452755c6101d1b678b6a48a0683f9bc779a7260 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Sat, 8 Jun 2024 00:34:42 +0200 Subject: [PATCH 02/18] [] added zitadel in readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1266113..fbf463e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Multi-provider authentication framework. * Twitch - `Assent.Strategy.Twitch` * Twitter - `Assent.Strategy.Twitter` * VK - `Assent.Strategy.VK` + * Zitadel - `Assent.Strategy.Zitadel` ## Installation From 9c30487a6c161fe0e9ed0544e96e6f3a0f7b53b4 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Thu, 11 Jul 2024 22:56:28 +0200 Subject: [PATCH 03/18] added nil authentication method needed for PKCE --- lib/assent/strategies/zitadel.ex | 4 ++-- test/assent/strategies/zitadel_test.exs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index bf2ce14..a0141c5 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -30,10 +30,10 @@ defmodule Assent.Strategy.Zitadel do "authorization_endpoint" => base_url <> "/oauth/v2/authorize", "token_endpoint" => base_url <> "/oauth/v2/token", "jwks_uri" => base_url <> "/oauth/v2/keys", - "token_endpoint_auth_methods_supported" => ["client_secret_post"] + "token_endpoint_auth_methods_supported" => ["client_secret_post", nil] }, authorization_params: [scope: "email", response_type: "code"], - client_authentication_method: "client_secret_post", + client_authentication_method: nil, openid_default_scope: "openid" ] end diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs index 03892ad..970628a 100644 --- a/test/assent/strategies/zitadel_test.exs +++ b/test/assent/strategies/zitadel_test.exs @@ -25,7 +25,7 @@ defmodule Assent.Strategy.ZitadelTest do "token_endpoint" => TestServer.url("/oauth/v2/token"), "userinfo_endpoint" => TestServer.url("/userinfo"), "jwks_uri" => TestServer.url("/jwks_uri.json"), - "token_endpoint_auth_methods_supported" => ["client_secret_post"] + "token_endpoint_auth_methods_supported" => ["client_secret_post", nil] } openid_configuration = From 776fb8e7e400a1d0fc2bf9fc0370f1eb6eb8e190 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Thu, 11 Jul 2024 23:01:02 +0200 Subject: [PATCH 04/18] removed optional param --- lib/assent/strategies/zitadel.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index a0141c5..bdba7ac 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -33,7 +33,6 @@ defmodule Assent.Strategy.Zitadel do "token_endpoint_auth_methods_supported" => ["client_secret_post", nil] }, authorization_params: [scope: "email", response_type: "code"], - client_authentication_method: nil, openid_default_scope: "openid" ] end From 00a67bd09cc4bb911c662ca087885cb0654753a3 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Thu, 11 Jul 2024 23:12:28 +0200 Subject: [PATCH 05/18] forcing none method --- lib/assent/strategies/zitadel.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index bdba7ac..d87c7c8 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -30,9 +30,10 @@ defmodule Assent.Strategy.Zitadel do "authorization_endpoint" => base_url <> "/oauth/v2/authorize", "token_endpoint" => base_url <> "/oauth/v2/token", "jwks_uri" => base_url <> "/oauth/v2/keys", - "token_endpoint_auth_methods_supported" => ["client_secret_post", nil] + "token_endpoint_auth_methods_supported" => ["none"] }, authorization_params: [scope: "email", response_type: "code"], + client_authentication_method: "none", openid_default_scope: "openid" ] end From e1b5cd12bc223ecefe0baf8ed4901f9985b8717e Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Thu, 11 Jul 2024 23:31:37 +0200 Subject: [PATCH 06/18] issuer must be given --- lib/assent/strategies/zitadel.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index d87c7c8..91e7d09 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -22,11 +22,12 @@ defmodule Assent.Strategy.Zitadel do @impl true def default_config(config) do {:ok, base_url} = Config.fetch(config, :base_url) + {:ok, issuer} = Config.fetch(config, :issuer) [ base_url: base_url, openid_configuration: %{ - "issuer" => "https://zitadel.cloud", + "issuer" => issuer, "authorization_endpoint" => base_url <> "/oauth/v2/authorize", "token_endpoint" => base_url <> "/oauth/v2/token", "jwks_uri" => base_url <> "/oauth/v2/keys", From c1c4b55987dc51cb73ee0f2795b3df8ebef0a494 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Fri, 12 Jul 2024 00:31:46 +0200 Subject: [PATCH 07/18] zitadel adds resource id in auds --- lib/assent/strategies/oidc.ex | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/assent/strategies/oidc.ex b/lib/assent/strategies/oidc.ex index 292d9ab..f5b334c 100644 --- a/lib/assent/strategies/oidc.ex +++ b/lib/assent/strategies/oidc.ex @@ -419,7 +419,10 @@ defmodule Assent.Strategy.OIDC do defp validate_audience(%{claims: %{"aud" => [client_id]}}, client_id, _config), do: :ok defp validate_audience(%{claims: %{"aud" => auds}}, client_id, config) do - trusted_audiences = Config.get(config, :trusted_audiences, []) ++ [client_id] + trusted_audiences = + (Config.get(config, :trusted_audiences, []) ++ [client_id]) + |> maybe_add_resource_id(config) + missing_client_id? = client_id not in auds untrusted_auds = Enum.filter(auds, &(&1 not in trusted_audiences)) @@ -435,6 +438,15 @@ defmodule Assent.Strategy.OIDC do end end + defp maybe_add_resource_id(trusted_audiences, config) do + with {:ok, resource_id} <- Config.fetch(config, :resource_id) do + trusted_audiences ++ [resource_id] + else + _ -> + trusted_audiences + end + end + defp validate_authorization_party(%{claims: %{"azp" => client_id}}, client_id, _config), do: :ok defp validate_authorization_party(%{claims: %{"azp" => azp}}, _client_id, _config) do From c2de27f85b5e6867257a9f10a3350df88689d763 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Fri, 12 Jul 2024 13:21:39 +0200 Subject: [PATCH 08/18] fixed tests --- lib/assent/strategies/zitadel.ex | 27 ++++++++++++++++++++++-- test/assent/strategies/zitadel_test.exs | 28 ++++++++----------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 91e7d09..5cb9f9f 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -2,12 +2,28 @@ defmodule Assent.Strategy.Zitadel do @moduledoc """ Zitadel Sign In OIDC strategy. + ## Needed settings + + Zitadel reccommended authentication implementation is OIDC with PKCE. + This means we need to add PKCE management in Oauth2 strategy and also + `code_verifier` management in Oidc strategy. + + I added a `none` `client_authentication_method` as per zitadel + authentication method configuration which maps to auth_method nil. + + I also added a resource_id parameter which is needed in Oidc strategy, + the Zitadel resource id is the id of the project, it is added by default + in the token `auds` and must be added in the trusted sources to avoid + `Untrusted audiences` error. + ## Usage ### Required configuration parameters config = [ base_url: "" + issuer: should be same of base url client_id: "REPLACE_WITH_CLIENT_ID", + resource_id: "REPLACE_WITH_RESOURCE_ID", redirect_uri: "http://localhost:4000/auth/callback", response_type: one of code, id_token token, id_token, scope: openid is required other options [email, profile] @@ -22,7 +38,14 @@ defmodule Assent.Strategy.Zitadel do @impl true def default_config(config) do {:ok, base_url} = Config.fetch(config, :base_url) - {:ok, issuer} = Config.fetch(config, :issuer) + issuer = get_in(config, [:openid_configuration, "issuer"]) + + if is_nil(issuer) do + {:error, Assent.Config.MissingKeyError.exception(key: "issuer")} + end + + client_authentication_method = + Config.get(config, :client_authentication_method, "none") [ base_url: base_url, @@ -34,7 +57,7 @@ defmodule Assent.Strategy.Zitadel do "token_endpoint_auth_methods_supported" => ["none"] }, authorization_params: [scope: "email", response_type: "code"], - client_authentication_method: "none", + client_authentication_method: client_authentication_method, openid_default_scope: "openid" ] end diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs index 970628a..d3bd0d1 100644 --- a/test/assent/strategies/zitadel_test.exs +++ b/test/assent/strategies/zitadel_test.exs @@ -3,11 +3,12 @@ defmodule Assent.Strategy.ZitadelTest do alias Assent.Strategy.Zitadel - @client_id "nameofproject@3425235252" + @client_id "3425235252@nameofproject" + @resource_id "3425296767" @id_token_claims %{ "iss" => "https://zitadel.cloud", "sub" => "001473.fe6f6f83bf4b8e4590aacbabdcb8598bd0.2039", - "aud" => @client_id, + "aud" => [@client_id, @resource_id], "exp" => :os.system_time(:second) + 5 * 60, "iat" => :os.system_time(:second), "email" => "john.doe@example.com", @@ -19,22 +20,20 @@ defmodule Assent.Strategy.ZitadelTest do } setup %{config: config, callback_params: callback_params} do - openid_config = %{ + openid_configuration = %{ "issuer" => "https://zitadel.cloud", "authorization_endpoint" => TestServer.url("/oauth/v2/authorize"), "token_endpoint" => TestServer.url("/oauth/v2/token"), "userinfo_endpoint" => TestServer.url("/userinfo"), "jwks_uri" => TestServer.url("/jwks_uri.json"), - "token_endpoint_auth_methods_supported" => ["client_secret_post", nil] + "token_endpoint_auth_methods_supported" => ["client_secret_post", "none"] } - openid_configuration = - Map.merge(Map.delete(config[:openid_configuration], "issuer"), openid_config) - config = Keyword.put(config, :openid_configuration, openid_configuration) + config = Keyword.put(config, :client_authentication_method, "none") callback_params = - Map.merge(callback_params, %{"nonce" => "123523", "code_verifier" => "456856"}) + Map.merge(callback_params, %{"code" => "123523", "state" => "456856"}) {:ok, config: config, callback_params: callback_params} end @@ -61,23 +60,14 @@ defmodule Assent.Strategy.ZitadelTest do test "callback/2", %{config: config, callback_params: params} do openid_config = config[:openid_configuration] - |> Map.put( - "issuer", - "https://zitadel.cloud" - ) - |> Map.put("token_endpoint_auth_methods_supported", [ - "client_secret_post", - "private_key_jwt", - "client_secret_basic" - ]) - session_params = Map.put(config[:session_params], :nonce, "123523") - session_params = Map.put(session_params, :code_verifier, "456856") + session_params = %{nonce: "123523", state: "456856", code_verifier: "ttt333qqq000"} config = Keyword.merge(config, openid_configuration: openid_config, client_id: @client_id, + resource_id: @resource_id, session_params: session_params ) From c5ac1498859147d5cf0c113892daa28498c81974 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Mon, 15 Jul 2024 17:48:48 +0200 Subject: [PATCH 09/18] issuer can be kept to first level configuration --- lib/assent/strategies/zitadel.ex | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 5cb9f9f..e8111ca 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -38,7 +38,7 @@ defmodule Assent.Strategy.Zitadel do @impl true def default_config(config) do {:ok, base_url} = Config.fetch(config, :base_url) - issuer = get_in(config, [:openid_configuration, "issuer"]) + {:ok, issuer} = Config.fetch(config, :issuer) if is_nil(issuer) do {:error, Assent.Config.MissingKeyError.exception(key: "issuer")} @@ -68,25 +68,4 @@ defmodule Assent.Strategy.Zitadel do config |> Base.callback(params, __MODULE__) end - - # @impl true - # def fetch_user(config, token) do - # with {:ok, user} <- OIDC.fetch_user(config, token), - # {:ok, user_info} <- Config.fetch(config, :user) do - # {:ok, Map.merge(user, user_info)} - # end - # end - - # @impl true - # def normalize(_config, user) do - # {:ok, - # %{ - # "sub" => user["sub"], - # "email" => user["email"], - # "email_verified" => true, - # "given_name" => Map.get(user, "name", %{})["firstName"], - # "family_name" => Map.get(user, "name", %{})["lastName"], - # "roles" => Map.get(user["profile"], "urn:zitadel:iam:org:project:roles") - # }} - # end end From a6418aa07cde4dcfff6a1f923fc4248b672f22e6 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Mon, 15 Jul 2024 18:03:33 +0200 Subject: [PATCH 10/18] added custom scope to be able to use reserved scopes --- lib/assent/strategies/zitadel.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index e8111ca..6e206e8 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -39,6 +39,7 @@ defmodule Assent.Strategy.Zitadel do def default_config(config) do {:ok, base_url} = Config.fetch(config, :base_url) {:ok, issuer} = Config.fetch(config, :issuer) + {:ok, scope} = Config.fetch(config, :scope) if is_nil(issuer) do {:error, Assent.Config.MissingKeyError.exception(key: "issuer")} @@ -56,7 +57,7 @@ defmodule Assent.Strategy.Zitadel do "jwks_uri" => base_url <> "/oauth/v2/keys", "token_endpoint_auth_methods_supported" => ["none"] }, - authorization_params: [scope: "email", response_type: "code"], + authorization_params: [scope: scope, response_type: "code"], client_authentication_method: client_authentication_method, openid_default_scope: "openid" ] From b74ba136752cb6ffab40cf9371531f567f56d77b Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Fri, 19 Jul 2024 17:54:26 +0200 Subject: [PATCH 11/18] better manage scopes and added prompt --- lib/assent/strategies/zitadel.ex | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 6e206e8..0ebd6e6 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -28,7 +28,8 @@ defmodule Assent.Strategy.Zitadel do response_type: one of code, id_token token, id_token, scope: openid is required other options [email, profile] code_challenge: The SHA-256 value of the generated code_verifier, - code_challenge_method: "S256" + code_challenge_method: "S256", + onboard: use Zitadel form to onboard users, true | false if true scope must include `urn:zitadel:iam:org:id:{id}` ] """ use Assent.Strategy.OIDC.Base @@ -39,7 +40,6 @@ defmodule Assent.Strategy.Zitadel do def default_config(config) do {:ok, base_url} = Config.fetch(config, :base_url) {:ok, issuer} = Config.fetch(config, :issuer) - {:ok, scope} = Config.fetch(config, :scope) if is_nil(issuer) do {:error, Assent.Config.MissingKeyError.exception(key: "issuer")} @@ -57,7 +57,10 @@ defmodule Assent.Strategy.Zitadel do "jwks_uri" => base_url <> "/oauth/v2/keys", "token_endpoint_auth_methods_supported" => ["none"] }, - authorization_params: [scope: scope, response_type: "code"], + authorization_params: + [response_type: "code"] + |> maybe_add(:scope, config) + |> maybe_add(:prompt, config), client_authentication_method: client_authentication_method, openid_default_scope: "openid" ] @@ -69,4 +72,12 @@ defmodule Assent.Strategy.Zitadel do config |> Base.callback(params, __MODULE__) end + + defp maybe_add(list, config_key, config) do + if value = Config.get(config, config_key, nil) do + list ++ {config_key, value} + else + list + end + end end From 0ff796f3c3cf4d2058fae40e48aa899ea1748fb4 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Fri, 19 Jul 2024 18:20:54 +0200 Subject: [PATCH 12/18] try to fix maybe add to scope --- lib/assent/strategies/zitadel.ex | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 0ebd6e6..7cce714 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -48,6 +48,11 @@ defmodule Assent.Strategy.Zitadel do client_authentication_method = Config.get(config, :client_authentication_method, "none") + authorization_params = + [response_type: "code"] + |> maybe_add(:scope, config) + |> maybe_add(:prompt, config) + [ base_url: base_url, openid_configuration: %{ @@ -57,10 +62,7 @@ defmodule Assent.Strategy.Zitadel do "jwks_uri" => base_url <> "/oauth/v2/keys", "token_endpoint_auth_methods_supported" => ["none"] }, - authorization_params: - [response_type: "code"] - |> maybe_add(:scope, config) - |> maybe_add(:prompt, config), + authorization_params: authorization_params, client_authentication_method: client_authentication_method, openid_default_scope: "openid" ] @@ -74,10 +76,9 @@ defmodule Assent.Strategy.Zitadel do end defp maybe_add(list, config_key, config) do - if value = Config.get(config, config_key, nil) do - list ++ {config_key, value} - else - list + case Config.get(config, config_key, nil) do + nil -> list + value -> list ++ {config_key, value} end end end From 66302d97eddd4ee4868d6d852b8945ff76244f64 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Fri, 19 Jul 2024 18:31:58 +0200 Subject: [PATCH 13/18] adding with another list --- lib/assent/strategies/zitadel.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 7cce714..b79f08e 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -78,7 +78,7 @@ defmodule Assent.Strategy.Zitadel do defp maybe_add(list, config_key, config) do case Config.get(config, config_key, nil) do nil -> list - value -> list ++ {config_key, value} + value -> list ++ [{config_key, value}] end end end From 7a7977eebca8960925cfecfc9547f202516811d5 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Tue, 30 Jul 2024 18:47:51 +0200 Subject: [PATCH 14/18] added multiple resource ids --- lib/assent/strategies/oidc.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/assent/strategies/oidc.ex b/lib/assent/strategies/oidc.ex index f5b334c..91abd5b 100644 --- a/lib/assent/strategies/oidc.ex +++ b/lib/assent/strategies/oidc.ex @@ -440,7 +440,16 @@ defmodule Assent.Strategy.OIDC do defp maybe_add_resource_id(trusted_audiences, config) do with {:ok, resource_id} <- Config.fetch(config, :resource_id) do - trusted_audiences ++ [resource_id] + cond do + is_binary(resource_id) -> + trusted_audiences ++ [resource_id] + + is_list(resource_id) -> + trusted_audiences ++ resource_id + + true -> + trusted_audiences + end else _ -> trusted_audiences From 325ec74c7ae02ebfb44ece9325e1e0c37a23ddc6 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Tue, 24 Sep 2024 12:03:10 +0200 Subject: [PATCH 15/18] added way to authenticate API application with Zitadel --- lib/assent/strategies/zitadel.ex | 88 ++++++++++++++++++++++++- test/assent/strategies/zitadel_test.exs | 57 ++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index b79f08e..8ab1fb8 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -27,6 +27,7 @@ defmodule Assent.Strategy.Zitadel do redirect_uri: "http://localhost:4000/auth/callback", response_type: one of code, id_token token, id_token, scope: openid is required other options [email, profile] + client_authentication_method: can use special :private_key_jwt_zitadel code_challenge: The SHA-256 value of the generated code_verifier, code_challenge_method: "S256", onboard: use Zitadel form to onboard users, true | false if true scope must include `urn:zitadel:iam:org:id:{id}` @@ -34,7 +35,16 @@ defmodule Assent.Strategy.Zitadel do """ use Assent.Strategy.OIDC.Base - alias Assent.{Config, Strategy.OIDC.Base} + alias Assent.Strategy, as: Helpers + + alias Assent.{ + Config, + Strategy.OIDC.Base, + JWTAdapter, + HTTPAdapter.HTTPResponse, + InvalidResponseError, + UnexpectedResponseError + } @impl true def default_config(config) do @@ -81,4 +91,80 @@ defmodule Assent.Strategy.Zitadel do value -> list ++ [{config_key, value}] end end + + @doc """ + Authenticates a zitadel api with JWT + """ + @spec authenticate_api(Config.t()) :: {:ok, map()} | {:error, term()} + def authenticate_api(config) do + token_url = Config.get(config, :token_url, "/oauth/v2/token") + + with {:ok, base_url} <- Config.__base_url__(config), + {:ok, auth_headers, params} <- jwt_authentication_params(config) do + headers = [{"content-type", "application/x-www-form-urlencoded"}] ++ auth_headers + url = Helpers.to_url(base_url, token_url) + body = URI.encode_query(params) + + :post + |> Helpers.request(url, body, headers, config) + |> process_access_token_response() + end + end + + defp process_access_token_response( + {:ok, %HTTPResponse{status: status, body: %{"access_token" => _} = token}} + ) + when status in [200, 201] do + {:ok, token} + end + + defp process_access_token_response(any), do: process_response(any) + + defp process_response({:ok, %HTTPResponse{} = response}), + do: {:error, UnexpectedResponseError.exception(response: response)} + + defp process_response({:error, %HTTPResponse{} = response}), + do: {:error, InvalidResponseError.exception(response: response)} + + defp process_response({:error, error}), do: {:error, error} + + defp jwt_authentication_params(config) do + with {:ok, token} <- gen_client_secret(config) do + headers = [] + + body = [ + scope: "openid", + assertion: token, + grant_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ] + + {:ok, headers, body} + end + end + + @jwt_expiration_seconds 3600 + + defp gen_client_secret(config) do + timestamp = :os.system_time(:second) + + config = + config + |> default_config() + |> Keyword.merge(config) + + with {:ok, base_url} <- Config.fetch(config, :base_url), + {:ok, client_id} <- Config.fetch(config, :client_id), + {:ok, _private_key_id} <- Config.fetch(config, :private_key_id), + {:ok, private_key} <- Config.fetch(config, :private_key) do + claims = %{ + "aud" => base_url, + "iss" => client_id, + "sub" => client_id, + "iat" => timestamp, + "exp" => timestamp + @jwt_expiration_seconds + } + + Helpers.sign_jwt(claims, "RS256", private_key, config) + end + end end diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs index d3bd0d1..37312fa 100644 --- a/test/assent/strategies/zitadel_test.exs +++ b/test/assent/strategies/zitadel_test.exs @@ -2,6 +2,22 @@ defmodule Assent.Strategy.ZitadelTest do use Assent.Test.OIDCTestCase alias Assent.Strategy.Zitadel + alias Plug.Conn + + @private_key_id "key_id" + @private_key """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 + OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r + 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G + -----END PRIVATE KEY----- + """ + @public_key """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 + q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== + -----END PUBLIC KEY----- + """ @client_id "3425235252@nameofproject" @resource_id "3425296767" @@ -81,4 +97,45 @@ defmodule Assent.Strategy.ZitadelTest do assert {:ok, %{user: user}} = Zitadel.callback(config, params) assert user == @user end + + test "authenticate_api/1", %{config: config} do + config = + Keyword.merge(config, + client_id: @client_id, + resource_id: @resource_id, + private_key: @private_key, + private_key_id: @private_key_id, + issuer: "https://zitadel.cloud" + ) + + expect_api_access_token_request() + + assert {:ok, %{"access_token" => "access_token"}} == Zitadel.authenticate_api(config) + end + + @spec expect_api_access_token_request(Keyword.t(), function() | nil) :: :ok + defp expect_api_access_token_request(opts \\ [], assert_fn \\ nil) do + access_token = Keyword.get(opts, :access_token, "access_token") + token_params = Keyword.get(opts, :params, %{access_token: access_token}) + uri = Keyword.get(opts, :uri, "/oauth/v2/token") + status_code = Keyword.get(opts, :status_code, 200) + + TestServer.add(uri, + via: :post, + to: fn conn -> + {:ok, body, _conn} = Plug.Conn.read_body(conn, []) + params = URI.decode_query(body) + + if assert_fn, do: assert_fn.(conn, params) + + send_json_resp(conn, token_params, status_code) + end + ) + end + + defp send_json_resp(conn, body, status_code) do + conn + |> Conn.put_resp_content_type("application/json") + |> Conn.send_resp(status_code, Jason.encode!(body)) + end end From 4c623006087283abd3ba59c694434bf9f825c172 Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Tue, 24 Sep 2024 12:46:56 +0200 Subject: [PATCH 16/18] fixed tests --- lib/assent/strategies/zitadel.ex | 1 - test/assent/strategies/zitadel_test.exs | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 8ab1fb8..2c4cdd5 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -40,7 +40,6 @@ defmodule Assent.Strategy.Zitadel do alias Assent.{ Config, Strategy.OIDC.Base, - JWTAdapter, HTTPAdapter.HTTPResponse, InvalidResponseError, UnexpectedResponseError diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs index 37312fa..5103030 100644 --- a/test/assent/strategies/zitadel_test.exs +++ b/test/assent/strategies/zitadel_test.exs @@ -37,7 +37,7 @@ defmodule Assent.Strategy.ZitadelTest do setup %{config: config, callback_params: callback_params} do openid_configuration = %{ - "issuer" => "https://zitadel.cloud", + # "issuer" => "https://zitadel.cloud", "authorization_endpoint" => TestServer.url("/oauth/v2/authorize"), "token_endpoint" => TestServer.url("/oauth/v2/token"), "userinfo_endpoint" => TestServer.url("/userinfo"), @@ -46,7 +46,9 @@ defmodule Assent.Strategy.ZitadelTest do } config = Keyword.put(config, :openid_configuration, openid_configuration) + config = Keyword.put(config, :openid_default_scope, "openid+email") config = Keyword.put(config, :client_authentication_method, "none") + config = Keyword.put(config, :issuer, "https://zitadel.cloud") callback_params = Map.merge(callback_params, %{"code" => "123523", "state" => "456856"}) @@ -57,14 +59,14 @@ defmodule Assent.Strategy.ZitadelTest do test "authorize_url/2", %{config: config} do assert {:ok, %{url: url}} = Zitadel.authorize_url(config) assert url =~ "/oauth/v2/authorize?client_id=id" - assert url =~ "scope=openid+email" + assert url =~ "scope=openid%2Bemail" assert url =~ "response_type=code" end test "authorize_url/2 with PKCE", %{config: config} do assert {:ok, %{url: url}} = Zitadel.authorize_url(config ++ [code_verifier: true, nonce: true]) assert url =~ "/oauth/v2/authorize?client_id=id" - assert url =~ "scope=openid+email" + assert url =~ "scope=openid%2Bemail" assert url =~ "response_type=code" assert url =~ "code_challenge=" assert url =~ "nonce=" @@ -75,7 +77,7 @@ defmodule Assent.Strategy.ZitadelTest do test "callback/2", %{config: config, callback_params: params} do openid_config = - config[:openid_configuration] + Map.merge(config[:openid_configuration], %{"issuer" => config[:issuer]}) session_params = %{nonce: "123523", state: "456856", code_verifier: "ttt333qqq000"} @@ -104,8 +106,7 @@ defmodule Assent.Strategy.ZitadelTest do client_id: @client_id, resource_id: @resource_id, private_key: @private_key, - private_key_id: @private_key_id, - issuer: "https://zitadel.cloud" + private_key_id: @private_key_id ) expect_api_access_token_request() From 5910c25ecdf522b0c903b8101b3a376ae1ccb28c Mon Sep 17 00:00:00 2001 From: Tommaso Patrizi Date: Tue, 24 Sep 2024 16:02:30 +0200 Subject: [PATCH 17/18] added token introspection --- lib/assent/strategies/zitadel.ex | 48 ++++++++++++++++++++++--- test/assent/strategies/zitadel_test.exs | 46 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index 2c4cdd5..a011c4c 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -110,6 +110,20 @@ defmodule Assent.Strategy.Zitadel do end end + defp jwt_authentication_params(config) do + with {:ok, token} <- gen_client_secret(config) do + headers = [] + + body = [ + scope: "openid", + assertion: token, + grant_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + ] + + {:ok, headers, body} + end + end + defp process_access_token_response( {:ok, %HTTPResponse{status: status, body: %{"access_token" => _} = token}} ) @@ -127,20 +141,46 @@ defmodule Assent.Strategy.Zitadel do defp process_response({:error, error}), do: {:error, error} - defp jwt_authentication_params(config) do + @doc """ + Introspects a given tokein with zitadel with JWT + """ + @spec introspect_token(Config.t(), binary()) :: {:ok, map()} | {:error, term()} + def introspect_token(config, access_token) do + introspect_url = Config.get(config, :introspect_url, "/oauth/v2/introspect") + + with {:ok, base_url} <- Config.__base_url__(config), + {:ok, auth_headers, params} <- jwt_introspection_params(config, access_token) do + headers = [{"content-type", "application/x-www-form-urlencoded"}] ++ auth_headers + url = Helpers.to_url(base_url, introspect_url) + body = URI.encode_query(params) + + :post + |> Helpers.request(url, body, headers, config) + |> process_introspect_token_response() + end + end + + defp jwt_introspection_params(config, access_token) do with {:ok, token} <- gen_client_secret(config) do headers = [] body = [ - scope: "openid", - assertion: token, - grant_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + client_assertion: token, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + token: access_token ] {:ok, headers, body} end end + defp process_introspect_token_response( + {:ok, %HTTPResponse{status: status, body: %{"active" => _} = token}} + ) + when status in [200, 201] do + {:ok, token} + end + @jwt_expiration_seconds 3600 defp gen_client_secret(config) do diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs index 5103030..3b6569e 100644 --- a/test/assent/strategies/zitadel_test.exs +++ b/test/assent/strategies/zitadel_test.exs @@ -114,6 +114,33 @@ defmodule Assent.Strategy.ZitadelTest do assert {:ok, %{"access_token" => "access_token"}} == Zitadel.authenticate_api(config) end + test "introspect_token/2", %{config: config} do + config = + Keyword.merge(config, + client_id: @client_id, + resource_id: @resource_id, + private_key: @private_key, + private_key_id: @private_key_id + ) + + expect_introspect_token_request( + response: %{ + active: true, + client_id: @client_id, + scope: "openid email profile", + username: "username@example.com" + } + ) + + assert {:ok, + %{ + "active" => true, + "client_id" => "3425235252@nameofproject", + "scope" => "openid email profile", + "username" => "username@example.com" + }} == Zitadel.introspect_token(config, "access_token") + end + @spec expect_api_access_token_request(Keyword.t(), function() | nil) :: :ok defp expect_api_access_token_request(opts \\ [], assert_fn \\ nil) do access_token = Keyword.get(opts, :access_token, "access_token") @@ -134,6 +161,25 @@ defmodule Assent.Strategy.ZitadelTest do ) end + @spec expect_introspect_token_request(Keyword.t(), function() | nil) :: :ok + defp expect_introspect_token_request(opts \\ [], assert_fn \\ nil) do + resp_body = Keyword.get(opts, :response) + uri = Keyword.get(opts, :uri, "/oauth/v2/introspect") + status_code = Keyword.get(opts, :status_code, 200) + + TestServer.add(uri, + via: :post, + to: fn conn -> + {:ok, body, _conn} = Plug.Conn.read_body(conn, []) + params = URI.decode_query(body) + + if assert_fn, do: assert_fn.(conn, params) + + send_json_resp(conn, resp_body, status_code) + end + ) + end + defp send_json_resp(conn, body, status_code) do conn |> Conn.put_resp_content_type("application/json") From cf541d6434027cbdcdd072a8bd66451492685117 Mon Sep 17 00:00:00 2001 From: Dan Schultzer <1254724+danschultzer@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:52:58 -0800 Subject: [PATCH 18/18] Refactor Zitadel strategy --- CHANGELOG.md | 1 + lib/assent/strategies/oidc.ex | 23 +-- lib/assent/strategies/zitadel.ex | 200 ++--------------------- test/assent/strategies/zitadel_test.exs | 203 ++++++------------------ 4 files changed, 67 insertions(+), 360 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8189fb..dd1c17b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * `Assent.Strategy.Bitbucket` added * `Assent.Strategy.Twitch` added * `Assent.Strategy.Telegram` added +* `Assent.Strategy.Zitadel` added * `Assent.Strategy.Facebook.fetch_user/2` fixed bug with user not being decoded * `Assent.Strategy.OAuth2` now supports PKCE * `Assent.Strategy.OAuth2.Base.authorize_url/2` incomplete typespec fixed diff --git a/lib/assent/strategies/oidc.ex b/lib/assent/strategies/oidc.ex index 91abd5b..292d9ab 100644 --- a/lib/assent/strategies/oidc.ex +++ b/lib/assent/strategies/oidc.ex @@ -419,10 +419,7 @@ defmodule Assent.Strategy.OIDC do defp validate_audience(%{claims: %{"aud" => [client_id]}}, client_id, _config), do: :ok defp validate_audience(%{claims: %{"aud" => auds}}, client_id, config) do - trusted_audiences = - (Config.get(config, :trusted_audiences, []) ++ [client_id]) - |> maybe_add_resource_id(config) - + trusted_audiences = Config.get(config, :trusted_audiences, []) ++ [client_id] missing_client_id? = client_id not in auds untrusted_auds = Enum.filter(auds, &(&1 not in trusted_audiences)) @@ -438,24 +435,6 @@ defmodule Assent.Strategy.OIDC do end end - defp maybe_add_resource_id(trusted_audiences, config) do - with {:ok, resource_id} <- Config.fetch(config, :resource_id) do - cond do - is_binary(resource_id) -> - trusted_audiences ++ [resource_id] - - is_list(resource_id) -> - trusted_audiences ++ resource_id - - true -> - trusted_audiences - end - else - _ -> - trusted_audiences - end - end - defp validate_authorization_party(%{claims: %{"azp" => client_id}}, client_id, _config), do: :ok defp validate_authorization_party(%{claims: %{"azp" => azp}}, _client_id, _config) do diff --git a/lib/assent/strategies/zitadel.ex b/lib/assent/strategies/zitadel.ex index a011c4c..57dab74 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -2,208 +2,38 @@ defmodule Assent.Strategy.Zitadel do @moduledoc """ Zitadel Sign In OIDC strategy. - ## Needed settings + ## Configuration - Zitadel reccommended authentication implementation is OIDC with PKCE. - This means we need to add PKCE management in Oauth2 strategy and also - `code_verifier` management in Oidc strategy. + - `:resource_id` - The resource id, required - I added a `none` `client_authentication_method` as per zitadel - authentication method configuration which maps to auth_method nil. - - I also added a resource_id parameter which is needed in Oidc strategy, - the Zitadel resource id is the id of the project, it is added by default - in the token `auds` and must be added in the trusted sources to avoid - `Untrusted audiences` error. + See `Assent.Strategy.OIDC` for more configuration options. ## Usage - ### Required configuration parameters config = [ - base_url: "" - issuer: should be same of base url + base_url: "REPLACE_WITH_ORGANIZATION_URL", client_id: "REPLACE_WITH_CLIENT_ID", - resource_id: "REPLACE_WITH_RESOURCE_ID", - redirect_uri: "http://localhost:4000/auth/callback", - response_type: one of code, id_token token, id_token, - scope: openid is required other options [email, profile] - client_authentication_method: can use special :private_key_jwt_zitadel - code_challenge: The SHA-256 value of the generated code_verifier, - code_challenge_method: "S256", - onboard: use Zitadel form to onboard users, true | false if true scope must include `urn:zitadel:iam:org:id:{id}` + resource_id: "REPLACE_WITH_RESOURCE_ID" ] """ use Assent.Strategy.OIDC.Base - alias Assent.Strategy, as: Helpers - - alias Assent.{ - Config, - Strategy.OIDC.Base, - HTTPAdapter.HTTPResponse, - InvalidResponseError, - UnexpectedResponseError - } + alias Assent.{Config, Strategy.OIDC} @impl true def default_config(config) do - {:ok, base_url} = Config.fetch(config, :base_url) - {:ok, issuer} = Config.fetch(config, :issuer) - - if is_nil(issuer) do - {:error, Assent.Config.MissingKeyError.exception(key: "issuer")} - end - - client_authentication_method = - Config.get(config, :client_authentication_method, "none") - - authorization_params = - [response_type: "code"] - |> maybe_add(:scope, config) - |> maybe_add(:prompt, config) + trusted_audiences = + config + |> Config.get(:resource_id, nil) + |> List.wrap() [ - base_url: base_url, - openid_configuration: %{ - "issuer" => issuer, - "authorization_endpoint" => base_url <> "/oauth/v2/authorize", - "token_endpoint" => base_url <> "/oauth/v2/token", - "jwks_uri" => base_url <> "/oauth/v2/keys", - "token_endpoint_auth_methods_supported" => ["none"] - }, - authorization_params: authorization_params, - client_authentication_method: client_authentication_method, - openid_default_scope: "openid" + authorization_params: [scope: "email profile"], + client_authentication_method: "none", + code_verifier: true, + trusted_audiences: trusted_audiences ] end - @doc false - @impl true - def callback(config, params) do - config - |> Base.callback(params, __MODULE__) - end - - defp maybe_add(list, config_key, config) do - case Config.get(config, config_key, nil) do - nil -> list - value -> list ++ [{config_key, value}] - end - end - - @doc """ - Authenticates a zitadel api with JWT - """ - @spec authenticate_api(Config.t()) :: {:ok, map()} | {:error, term()} - def authenticate_api(config) do - token_url = Config.get(config, :token_url, "/oauth/v2/token") - - with {:ok, base_url} <- Config.__base_url__(config), - {:ok, auth_headers, params} <- jwt_authentication_params(config) do - headers = [{"content-type", "application/x-www-form-urlencoded"}] ++ auth_headers - url = Helpers.to_url(base_url, token_url) - body = URI.encode_query(params) - - :post - |> Helpers.request(url, body, headers, config) - |> process_access_token_response() - end - end - - defp jwt_authentication_params(config) do - with {:ok, token} <- gen_client_secret(config) do - headers = [] - - body = [ - scope: "openid", - assertion: token, - grant_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - ] - - {:ok, headers, body} - end - end - - defp process_access_token_response( - {:ok, %HTTPResponse{status: status, body: %{"access_token" => _} = token}} - ) - when status in [200, 201] do - {:ok, token} - end - - defp process_access_token_response(any), do: process_response(any) - - defp process_response({:ok, %HTTPResponse{} = response}), - do: {:error, UnexpectedResponseError.exception(response: response)} - - defp process_response({:error, %HTTPResponse{} = response}), - do: {:error, InvalidResponseError.exception(response: response)} - - defp process_response({:error, error}), do: {:error, error} - - @doc """ - Introspects a given tokein with zitadel with JWT - """ - @spec introspect_token(Config.t(), binary()) :: {:ok, map()} | {:error, term()} - def introspect_token(config, access_token) do - introspect_url = Config.get(config, :introspect_url, "/oauth/v2/introspect") - - with {:ok, base_url} <- Config.__base_url__(config), - {:ok, auth_headers, params} <- jwt_introspection_params(config, access_token) do - headers = [{"content-type", "application/x-www-form-urlencoded"}] ++ auth_headers - url = Helpers.to_url(base_url, introspect_url) - body = URI.encode_query(params) - - :post - |> Helpers.request(url, body, headers, config) - |> process_introspect_token_response() - end - end - - defp jwt_introspection_params(config, access_token) do - with {:ok, token} <- gen_client_secret(config) do - headers = [] - - body = [ - client_assertion: token, - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - token: access_token - ] - - {:ok, headers, body} - end - end - - defp process_introspect_token_response( - {:ok, %HTTPResponse{status: status, body: %{"active" => _} = token}} - ) - when status in [200, 201] do - {:ok, token} - end - - @jwt_expiration_seconds 3600 - - defp gen_client_secret(config) do - timestamp = :os.system_time(:second) - - config = - config - |> default_config() - |> Keyword.merge(config) - - with {:ok, base_url} <- Config.fetch(config, :base_url), - {:ok, client_id} <- Config.fetch(config, :client_id), - {:ok, _private_key_id} <- Config.fetch(config, :private_key_id), - {:ok, private_key} <- Config.fetch(config, :private_key) do - claims = %{ - "aud" => base_url, - "iss" => client_id, - "sub" => client_id, - "iat" => timestamp, - "exp" => timestamp + @jwt_expiration_seconds - } - - Helpers.sign_jwt(claims, "RS256", private_key, config) - end - end + def fetch_user(config, token), do: OIDC.fetch_userinfo(config, token) end diff --git a/test/assent/strategies/zitadel_test.exs b/test/assent/strategies/zitadel_test.exs index 3b6569e..189ee12 100644 --- a/test/assent/strategies/zitadel_test.exs +++ b/test/assent/strategies/zitadel_test.exs @@ -2,187 +2,84 @@ defmodule Assent.Strategy.ZitadelTest do use Assent.Test.OIDCTestCase alias Assent.Strategy.Zitadel - alias Plug.Conn - - @private_key_id "key_id" - @private_key """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - @public_key """ - -----BEGIN PUBLIC KEY----- - MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 - q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== - -----END PUBLIC KEY----- - """ @client_id "3425235252@nameofproject" @resource_id "3425296767" @id_token_claims %{ - "iss" => "https://zitadel.cloud", - "sub" => "001473.fe6f6f83bf4b8e4590aacbabdcb8598bd0.2039", + "iss" => "https://subdomain.region.zitadel.cloud", + "sub" => "299884084107794421", "aud" => [@client_id, @resource_id], "exp" => :os.system_time(:second) + 5 * 60, "iat" => :os.system_time(:second), + "auth_time" => :os.system_time(:second) - 60, + "amr" => ["pwd"], + "azp" => @client_id, + "client_id" => @client_id, + "at_hash" => "at_hash", + "sid" => "sid" + } + @userinfo %{ "email" => "john.doe@example.com", - "nonce" => "123523" + "email_verified" => true, + "family_name" => "Admin", + "given_name" => "ZITADEL", + "locale" => "en", + "name" => "ZITADEL Admin", + "preferred_username" => "john.doe@example.com", + "sub" => "299884084107794421", + "updated_at" => 1_735_240_843 } @user %{ + "sub" => "299884084107794421", "email" => "john.doe@example.com", - "sub" => "001473.fe6f6f83bf4b8e4590aacbabdcb8598bd0.2039" + "email_verified" => true, + "family_name" => "Admin", + "given_name" => "ZITADEL", + "locale" => "en", + "name" => "ZITADEL Admin", + "preferred_username" => "john.doe@example.com", + "updated_at" => 1_735_240_843 } - setup %{config: config, callback_params: callback_params} do - openid_configuration = %{ - # "issuer" => "https://zitadel.cloud", - "authorization_endpoint" => TestServer.url("/oauth/v2/authorize"), - "token_endpoint" => TestServer.url("/oauth/v2/token"), - "userinfo_endpoint" => TestServer.url("/userinfo"), - "jwks_uri" => TestServer.url("/jwks_uri.json"), - "token_endpoint_auth_methods_supported" => ["client_secret_post", "none"] - } - - config = Keyword.put(config, :openid_configuration, openid_configuration) - config = Keyword.put(config, :openid_default_scope, "openid+email") - config = Keyword.put(config, :client_authentication_method, "none") - config = Keyword.put(config, :issuer, "https://zitadel.cloud") - - callback_params = - Map.merge(callback_params, %{"code" => "123523", "state" => "456856"}) - - {:ok, config: config, callback_params: callback_params} - end - - test "authorize_url/2", %{config: config} do - assert {:ok, %{url: url}} = Zitadel.authorize_url(config) - assert url =~ "/oauth/v2/authorize?client_id=id" - assert url =~ "scope=openid%2Bemail" - assert url =~ "response_type=code" - end - - test "authorize_url/2 with PKCE", %{config: config} do - assert {:ok, %{url: url}} = Zitadel.authorize_url(config ++ [code_verifier: true, nonce: true]) - assert url =~ "/oauth/v2/authorize?client_id=id" - assert url =~ "scope=openid%2Bemail" - assert url =~ "response_type=code" - assert url =~ "code_challenge=" - assert url =~ "nonce=" - assert url =~ "state=" - assert url =~ "code_challenge_method=S256" - assert not String.match?(url, ~r/code_verifier/) - end - - test "callback/2", %{config: config, callback_params: params} do - openid_config = - Map.merge(config[:openid_configuration], %{"issuer" => config[:issuer]}) - - session_params = %{nonce: "123523", state: "456856", code_verifier: "ttt333qqq000"} - - config = - Keyword.merge(config, - openid_configuration: openid_config, - client_id: @client_id, - resource_id: @resource_id, - session_params: session_params - ) - - [key | _rest] = expect_oidc_jwks_uri_request() - - expect_oidc_access_token_request( - id_token_opts: [claims: @id_token_claims, kid: key["kid"]], - uri: "/oauth/v2/token" - ) + setup %{config: config} do + openid_configuration = + Map.put(config[:openid_configuration], "issuer", "https://subdomain.region.zitadel.cloud") - assert {:ok, %{user: user}} = Zitadel.callback(config, params) - assert user == @user - end + session_params = Map.put(config[:session_params], :code_verifier, "code_verifier_value") - test "authenticate_api/1", %{config: config} do config = - Keyword.merge(config, + Keyword.merge( + config, client_id: @client_id, - resource_id: @resource_id, - private_key: @private_key, - private_key_id: @private_key_id + openid_configuration: openid_configuration, + session_params: session_params, + resource_id: @resource_id ) - expect_api_access_token_request() - - assert {:ok, %{"access_token" => "access_token"}} == Zitadel.authenticate_api(config) + {:ok, config: config} end - test "introspect_token/2", %{config: config} do - config = - Keyword.merge(config, - client_id: @client_id, - resource_id: @resource_id, - private_key: @private_key, - private_key_id: @private_key_id - ) - - expect_introspect_token_request( - response: %{ - active: true, - client_id: @client_id, - scope: "openid email profile", - username: "username@example.com" - } - ) + test "authorize_url/2", %{config: config} do + assert {:ok, %{url: url, session_params: session_params}} = Zitadel.authorize_url(config) - assert {:ok, - %{ - "active" => true, - "client_id" => "3425235252@nameofproject", - "scope" => "openid email profile", - "username" => "username@example.com" - }} == Zitadel.introspect_token(config, "access_token") - end + assert session_params[:code_verifier] - @spec expect_api_access_token_request(Keyword.t(), function() | nil) :: :ok - defp expect_api_access_token_request(opts \\ [], assert_fn \\ nil) do - access_token = Keyword.get(opts, :access_token, "access_token") - token_params = Keyword.get(opts, :params, %{access_token: access_token}) - uri = Keyword.get(opts, :uri, "/oauth/v2/token") - status_code = Keyword.get(opts, :status_code, 200) + url = URI.parse(url) - TestServer.add(uri, - via: :post, - to: fn conn -> - {:ok, body, _conn} = Plug.Conn.read_body(conn, []) - params = URI.decode_query(body) + assert url.path == "/oauth/authorize" - if assert_fn, do: assert_fn.(conn, params) + assert %{"client_id" => @client_id, "scope" => scope, "code_challenge_method" => "S256"} = + URI.decode_query(url.query) - send_json_resp(conn, token_params, status_code) - end - ) + assert scope =~ "email profile" end - @spec expect_introspect_token_request(Keyword.t(), function() | nil) :: :ok - defp expect_introspect_token_request(opts \\ [], assert_fn \\ nil) do - resp_body = Keyword.get(opts, :response) - uri = Keyword.get(opts, :uri, "/oauth/v2/introspect") - status_code = Keyword.get(opts, :status_code, 200) - - TestServer.add(uri, - via: :post, - to: fn conn -> - {:ok, body, _conn} = Plug.Conn.read_body(conn, []) - params = URI.decode_query(body) - - if assert_fn, do: assert_fn.(conn, params) - - send_json_resp(conn, resp_body, status_code) - end - ) - end + test "callback/2", %{config: config, callback_params: params} do + [key | _rest] = expect_oidc_jwks_uri_request() + expect_oidc_access_token_request(id_token_opts: [claims: @id_token_claims, kid: key["kid"]]) + expect_oidc_userinfo_request(@userinfo) - defp send_json_resp(conn, body, status_code) do - conn - |> Conn.put_resp_content_type("application/json") - |> Conn.send_resp(status_code, Jason.encode!(body)) + assert {:ok, %{user: user}} = Zitadel.callback(config, params) + assert user == @user end end