From 12d4625c7e8d6f9ec32b7c0d28857214a40dea51 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] 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, 68 insertions(+), 359 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bfff0..d8552f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * `Assent.Strategy.Bitbucket` added * `Assent.Strategy.Twitch` added * `Assent.Strategy.OAuth2` now supports PKCE +* `Assent.Strategy.Zitadel` added ## v0.2.10 (2024-04-11) diff --git a/lib/assent/strategies/oidc.ex b/lib/assent/strategies/oidc.ex index d9abe59..1167118 100644 --- a/lib/assent/strategies/oidc.ex +++ b/lib/assent/strategies/oidc.ex @@ -404,10 +404,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)) @@ -423,24 +420,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..d91aef8 100644 --- a/lib/assent/strategies/zitadel.ex +++ b/lib/assent/strategies/zitadel.ex @@ -2,208 +2,40 @@ 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 @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 + def fetch_user(config, token) do + Assent.Strategy.OIDC.fetch_userinfo(config, token) end 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