diff --git a/.env.template b/.env.template index fe581fe880..aec40f53eb 100644 --- a/.env.template +++ b/.env.template @@ -31,3 +31,9 @@ REDIS_STANDALONE=true # Dashboard, the admin page, and features under the /preview path. # BASIC_AUTH_READONLY_USERNAME= # BASIC_AUTH_READONLY_PASSWORD= + +# These credentials enable access to Tableau Cloud content which we are embedding in various pages. +# TABLEAU_USER= +# TABLEAU_CLIENT_ID= +# TABLEAU_SECRET_ID= +# TABLEAU_SECRET_VALUE= diff --git a/config/runtime.exs b/config/runtime.exs index c5daed3868..629e61eec6 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -207,6 +207,16 @@ config :dotcom, DotcomWeb.ViewHelpers, config :dotcom, google_api_key: System.get_env("GOOGLE_API_KEY") +config :joken, + tableau_signer: [ + signer_alg: "HS256", + key_octet: System.get_env("TABLEAU_SECRET_VALUE"), + jose_extra_headers: %{ + "kid" => System.get_env("TABLEAU_SECRET_ID"), + "iss" => System.get_env("TABLEAU_CLIENT_ID") + } + ] + config :recaptcha, public_key: System.get_env("RECAPTCHA_PUBLIC_KEY"), secret: System.get_env("RECAPTCHA_PRIVATE_KEY", "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe") diff --git a/config/test.exs b/config/test.exs index c655c44ffe..40ebe48c31 100644 --- a/config/test.exs +++ b/config/test.exs @@ -37,6 +37,7 @@ config :dotcom, :redix_pub_sub, Dotcom.Redix.PubSub.Mock config :dotcom, :otp_module, OpenTripPlannerClient.Mock config :dotcom, :req_module, Req.Mock +config :dotcom, :tableau_cloud_token_module, TableauCloudToken.Mock config :dotcom, :trip_plan_feedback_cache, Dotcom.Cache.TestCache # Let test requests get routed through the :secure pipeline diff --git a/lib/dotcom/content_rewriters/code_embed.ex b/lib/dotcom/content_rewriters/code_embed.ex new file mode 100644 index 0000000000..33aaae504a --- /dev/null +++ b/lib/dotcom/content_rewriters/code_embed.ex @@ -0,0 +1,34 @@ +defmodule Dotcom.ContentRewriters.CodeEmbed do + @moduledoc """ + Code embeds should not be modified unless necessary! + """ + + @tableau_cloud_token_module Application.compile_env( + :dotcom, + :tableau_cloud_token_module, + TableauCloudToken + ) + + @spec rewrite(Phoenix.HTML.safe()) :: Phoenix.HTML.safe() + def rewrite({:safe, content}) do + {:ok, parsed} = Floki.parse_fragment(content) + + parsed + |> Enum.map(&dispatch_rewrites/1) + |> Floki.raw_html(encode: false) + |> Phoenix.HTML.raw() + end + + # Tableau dashboards: add the JWT needed for authentication + @spec dispatch_rewrites(Floki.html_tree()) :: Floki.html_tree() + defp dispatch_rewrites({"tableau-viz", attrs, children}) do + attrs = [{"token", @tableau_cloud_token_module.default_token()} | attrs] + {"tableau-viz", attrs, children} + end + + defp dispatch_rewrites({name, attrs, children}) when is_list(children) do + {name, attrs, Enum.map(children, &dispatch_rewrites/1)} + end + + defp dispatch_rewrites(element), do: element +end diff --git a/lib/dotcom_web/plugs/content_security_policy.ex b/lib/dotcom_web/plugs/content_security_policy.ex index d469131831..e9518facc8 100644 --- a/lib/dotcom_web/plugs/content_security_policy.ex +++ b/lib/dotcom_web/plugs/content_security_policy.ex @@ -12,6 +12,7 @@ defmodule DotcomWeb.Plugs.ContentSecurityPolicy do 'self' #{@tile_server_url} *.arcgis.com + *.tableau.com analytics.google.com cdn.mbta.com px.ads.linkedin.com @@ -27,6 +28,7 @@ defmodule DotcomWeb.Plugs.ContentSecurityPolicy do frame_src: ~w[ 'self' *.arcgis.com + *.tableau.com *.soundcloud.com *.vimeo.com cdn.knightlab.com diff --git a/lib/dotcom_web/templates/cms/paragraph/_code_embed.html.eex b/lib/dotcom_web/templates/cms/paragraph/_code_embed.html.eex index ca0d31f1fa..a1191b0ce7 100644 --- a/lib/dotcom_web/templates/cms/paragraph/_code_embed.html.eex +++ b/lib/dotcom_web/templates/cms/paragraph/_code_embed.html.eex @@ -2,5 +2,4 @@ <%= ContentRewriter.rewrite(@content.header.text, @conn) %> <% end %> -<%# Output raw CMS source HTML %> -<%= @content.body %> +<%= Dotcom.ContentRewriters.CodeEmbed.rewrite(@content.body) %> diff --git a/lib/tableau_cloud_token.ex b/lib/tableau_cloud_token.ex new file mode 100644 index 0000000000..581906c280 --- /dev/null +++ b/lib/tableau_cloud_token.ex @@ -0,0 +1,33 @@ +defmodule TableauCloudToken do + @moduledoc """ + Configuration for using JSON Web Tokens with our Tableau Cloud server. + + https://help.tableau.com/current/online/en-us/connected_apps_direct.htm#step-3-configure-the-jwt + """ + use Joken.Config, default_signer: :tableau_signer + + @impl Joken.Config + def token_config do + default_claims( + aud: "tableau", + iss: System.get_env("TABLEAU_CLIENT_ID"), + default_exp: 300 + ) + |> add_claim("kid", fn -> System.get_env("TABLEAU_SECRET_ID") end) + |> add_claim("sub", fn -> System.get_env("TABLEAU_USER") end) + |> add_claim("scp", fn -> ["tableau:views:embed", "tableau:metrics:embed"] end) + end + + @behaviour __MODULE__ + @callback default_token() :: Joken.bearer_token() | {:error, Joken.error_reason()} + @doc """ + Returns a valid token for using with embedding Tableau Cloud visualizations + """ + @impl __MODULE__ + def default_token do + with {:ok, token, _} <- generate_and_sign(), + {:ok, _claims} <- verify_and_validate(token) do + token + end + end +end diff --git a/mix.exs b/mix.exs index 9493931225..165b8f8747 100644 --- a/mix.exs +++ b/mix.exs @@ -107,6 +107,7 @@ defmodule DotCom.Mixfile do {:httpoison, "2.2.2"}, {:inflex, "2.1.0"}, {:jason, "1.4.4", override: true}, + {:joken, "2.6.2"}, {:kino_live_component, "0.0.5"}, {:logster, "1.1.1"}, # reverted from 0.4 diff --git a/mix.lock b/mix.lock index d9c7eb3b24..c25e79bd90 100644 --- a/mix.lock +++ b/mix.lock @@ -51,6 +51,8 @@ "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iso8601": {:hex, :iso8601, "1.3.4", "7b1f095f86f6cf65e1e5a77872e8e8bf69bd58d4c3a415b3f77d9cc9423ecbb9", [:rebar3], [], "hexpm", "a334469c07f1c219326bc891a95f5eec8eb12dd8071a3fff56a7843cb20fae34"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, "kino": {:hex, :kino, "0.15.3", "c99e21fc3e5d89513120295b91efc3efd18f7c1fb83875edced9d06ada13a2c0", [:mix], [{:fss, "~> 0.1.0", [hex: :fss, repo: "hexpm", optional: false]}, {:nx, "~> 0.1", [hex: :nx, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:table, "~> 0.1.2", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "11f62457ce6ac97ad377db9fcde168361fcf0de7db2a47b6f570607dc7897753"}, "kino_live_component": {:hex, :kino_live_component, "0.0.5", "0d9a222b296a568dce6db646219800ddae75e4e7bc8bfb70e7d4d7e759321d19", [:mix], [{:bandit, "~> 1.6", [hex: :bandit, repo: "hexpm", optional: false]}, {:cors_plug, ">= 3.0.0", [hex: :cors_plug, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:kino, "~> 0.14", [hex: :kino, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "041ee6bc09e284bfd10882be6bd7000774259d677023385ebbfd15267598ed47"}, diff --git a/test/dotcom/content_rewriters/code_embed_test.exs b/test/dotcom/content_rewriters/code_embed_test.exs new file mode 100644 index 0000000000..4292e5adf1 --- /dev/null +++ b/test/dotcom/content_rewriters/code_embed_test.exs @@ -0,0 +1,30 @@ +defmodule Dotcom.ContentRewriters.CodeEmbedTest do + use ExUnit.Case + + import Dotcom.ContentRewriters.CodeEmbed + import Mox + + setup :verify_on_exit! + + describe "rewrite/1" do + test "finds and adds a token attribute" do + token = Faker.String.base64(50) + expect(TableauCloudToken.Mock, :default_token, fn -> token end) + + content = + {:safe, + ~s()} + + {:safe, rewritten} = rewrite(content) + {:ok, fragment} = Floki.parse_fragment(rewritten) + [{_, attrs, _}] = Floki.find(fragment, "tableau-viz") + assert Enum.find(attrs, fn {name, ^token} -> name == "token" end) + end + + test "passes through other code embeds" do + input_code = ~s(ABCD) + {:safe, rewritten} = rewrite({:safe, input_code}) + assert rewritten == input_code + end + end +end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 93b59322d8..603d3b4565 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -17,6 +17,7 @@ Mox.defmock(OpenTripPlannerClient.Mock, for: OpenTripPlannerClient.Behaviour) Mox.defmock(Predictions.Phoenix.PubSub.Mock, for: Phoenix.Channel) Mox.defmock(Predictions.PubSub.Mock, for: [GenServer, Predictions.PubSub.Behaviour]) Mox.defmock(Predictions.Store.Mock, for: Predictions.Store.Behaviour) +Mox.defmock(TableauCloudToken.Mock, for: TableauCloudToken) # Repos Mox.defmock(Alerts.Repo.Mock, for: Alerts.Repo.Behaviour)