From a41d59d35974e623e9e890ac99eceb6d201f3978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 17 Dec 2025 23:37:25 +0100 Subject: [PATCH 1/3] Use OAuth --- config/config.exs | 3 + config/runtime.exs | 2 + lib/hexdocs/hexpm/impl.ex | 14 +- lib/hexdocs/http.ex | 1 + lib/hexdocs/oauth.ex | 186 +++++++++++++++ lib/hexdocs/plug.ex | 234 ++++++++++++++----- test/hexdocs/oauth_test.exs | 154 +++++++++++++ test/hexdocs/plug_test.exs | 444 ++++++++++++++++++++++-------------- 8 files changed, 806 insertions(+), 232 deletions(-) create mode 100644 lib/hexdocs/oauth.ex create mode 100644 test/hexdocs/oauth_test.exs diff --git a/config/config.exs b/config/config.exs index d926540..e200cf0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,6 +4,9 @@ config :hexdocs, port: "4002", hexpm_url: "http://localhost:4000", hexpm_secret: "2cd6d09334d4b00a2be4d532342b799b", + # OAuth client credentials for hexpm integration + oauth_client_id: "hexdocs", + oauth_client_secret: "dev_secret_for_testing", typesense_url: "http://localhost:8108", typesense_api_key: "hexdocs", typesense_collection: "hexdocs", diff --git a/config/runtime.exs b/config/runtime.exs index 4fe0a02..9eeac79 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -5,6 +5,8 @@ if config_env() == :prod do port: System.fetch_env!("HEXDOCS_PORT"), hexpm_url: System.fetch_env!("HEXDOCS_HEXPM_URL"), hexpm_secret: System.fetch_env!("HEXDOCS_HEXPM_SECRET"), + oauth_client_id: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_ID"), + oauth_client_secret: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_SECRET"), typesense_url: System.fetch_env!("HEXDOCS_TYPESENSE_URL"), typesense_api_key: System.fetch_env!("HEXDOCS_TYPESENSE_API_KEY"), typesense_collection: System.fetch_env!("HEXDOCS_TYPESENSE_COLLECTION"), diff --git a/lib/hexdocs/hexpm/impl.ex b/lib/hexdocs/hexpm/impl.ex index b47c556..98c4c96 100644 --- a/lib/hexdocs/hexpm/impl.ex +++ b/lib/hexdocs/hexpm/impl.ex @@ -56,10 +56,20 @@ defmodule Hexdocs.Hexpm.Impl do Application.get_env(:hexdocs, :hexpm_url) <> path end - defp headers(key) do + defp headers(key_or_token) do + # Support both legacy API keys and OAuth Bearer tokens + # OAuth tokens are JWTs that start with "eyJ" (base64 of '{"') + # Legacy API keys are shorter hex strings + authorization = + if String.starts_with?(key_or_token, "eyJ") do + "Bearer #{key_or_token}" + else + key_or_token + end + [ {"accept", "application/json"}, - {"authorization", key} + {"authorization", authorization} ] end end diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index 687dfb2..3148a1d 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -25,6 +25,7 @@ defmodule Hexdocs.HTTP do def post(url, headers, body, opts \\ []) do :hackney.post(url, headers, body, opts) + |> read_response() end def delete(url, headers, opts \\ []) do diff --git a/lib/hexdocs/oauth.ex b/lib/hexdocs/oauth.ex new file mode 100644 index 0000000..bfb77b2 --- /dev/null +++ b/lib/hexdocs/oauth.ex @@ -0,0 +1,186 @@ +defmodule Hexdocs.OAuth do + @moduledoc """ + OAuth 2.0 Authorization Code with PKCE client for hexdocs. + + This module implements the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for + Code Exchange) as defined in RFC 7636. It can be used by any application integrating + with hexpm's OAuth infrastructure. + + ## Flow + + 1. Generate code_verifier and code_challenge using `generate_code_verifier/0` and + `generate_code_challenge/1` + 2. Build authorization URL with `authorization_url/1` and redirect user + 3. After user authorizes, exchange the code for tokens with `exchange_code/3` + 4. Use `refresh_token/2` to get new access tokens before expiration + """ + + @doc """ + Generate a cryptographically random code_verifier for PKCE. + + Returns a 43-character URL-safe base64 string (32 random bytes encoded). + """ + def generate_code_verifier do + :crypto.strong_rand_bytes(32) + |> Base.url_encode64(padding: false) + end + + @doc """ + Generate code_challenge from code_verifier using S256 method. + + Computes SHA-256 hash of the verifier and base64url encodes it. + """ + def generate_code_challenge(verifier) do + :crypto.hash(:sha256, verifier) + |> Base.url_encode64(padding: false) + end + + @doc """ + Generate a random state parameter for CSRF protection. + """ + def generate_state do + :crypto.strong_rand_bytes(16) + |> Base.url_encode64(padding: false) + end + + @doc """ + Build the OAuth authorization URL with PKCE parameters. + + ## Options (all required) + + * `:hexpm_url` - Base URL of hexpm (e.g., "https://hex.pm") + * `:client_id` - OAuth client ID + * `:redirect_uri` - URI to redirect to after authorization + * `:scope` - Space-separated scopes to request + * `:state` - Random state for CSRF protection + * `:code_challenge` - PKCE code challenge + + """ + def authorization_url(opts) do + hexpm_url = Keyword.fetch!(opts, :hexpm_url) + client_id = Keyword.fetch!(opts, :client_id) + redirect_uri = Keyword.fetch!(opts, :redirect_uri) + scope = Keyword.fetch!(opts, :scope) + state = Keyword.fetch!(opts, :state) + code_challenge = Keyword.fetch!(opts, :code_challenge) + + query = + URI.encode_query(%{ + "response_type" => "code", + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "scope" => scope, + "state" => state, + "code_challenge" => code_challenge, + "code_challenge_method" => "S256" + }) + + "#{hexpm_url}/oauth/authorize?#{query}" + end + + @doc """ + Exchange an authorization code for access and refresh tokens. + + ## Parameters + + * `code` - The authorization code received from the callback + * `code_verifier` - The original code_verifier generated before authorization + * `opts` - Keyword list with: + * `:hexpm_url` - Base URL of hexpm + * `:client_id` - OAuth client ID + * `:client_secret` - OAuth client secret + * `:redirect_uri` - The same redirect_uri used in authorization + + ## Returns + + * `{:ok, tokens}` - Map with "access_token", "refresh_token", "expires_in", etc. + * `{:error, reason}` - Error tuple with status code and error response + """ + def exchange_code(code, code_verifier, opts) do + hexpm_url = Keyword.fetch!(opts, :hexpm_url) + client_id = Keyword.fetch!(opts, :client_id) + client_secret = Keyword.fetch!(opts, :client_secret) + redirect_uri = Keyword.fetch!(opts, :redirect_uri) + + body = + Jason.encode!(%{ + "grant_type" => "authorization_code", + "code" => code, + "redirect_uri" => redirect_uri, + "client_id" => client_id, + "client_secret" => client_secret, + "code_verifier" => code_verifier + }) + + url = "#{hexpm_url}/api/oauth/token" + headers = [{"content-type", "application/json"}] + + case Hexdocs.HTTP.post(url, headers, body) do + {:ok, status, _headers, response_body} when status in 200..299 -> + {:ok, Jason.decode!(response_body)} + + {:ok, status, _headers, response_body} -> + {:error, {status, Jason.decode!(response_body)}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Refresh an access token using a refresh token. + + ## Parameters + + * `refresh_token` - The refresh token from a previous token response + * `opts` - Keyword list with: + * `:hexpm_url` - Base URL of hexpm + * `:client_id` - OAuth client ID + * `:client_secret` - OAuth client secret + + ## Returns + + * `{:ok, tokens}` - Map with new "access_token", "refresh_token", "expires_in", etc. + * `{:error, reason}` - Error tuple + """ + def refresh_token(refresh_token, opts) do + hexpm_url = Keyword.fetch!(opts, :hexpm_url) + client_id = Keyword.fetch!(opts, :client_id) + client_secret = Keyword.fetch!(opts, :client_secret) + + body = + Jason.encode!(%{ + "grant_type" => "refresh_token", + "refresh_token" => refresh_token, + "client_id" => client_id, + "client_secret" => client_secret + }) + + url = "#{hexpm_url}/api/oauth/token" + headers = [{"content-type", "application/json"}] + + case Hexdocs.HTTP.post(url, headers, body) do + {:ok, status, _headers, response_body} when status in 200..299 -> + {:ok, Jason.decode!(response_body)} + + {:ok, status, _headers, response_body} -> + {:error, {status, Jason.decode!(response_body)}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Get the OAuth configuration from application environment. + + Returns a keyword list with all OAuth settings needed for API calls. + """ + def config do + [ + hexpm_url: Application.get_env(:hexdocs, :hexpm_url), + client_id: Application.get_env(:hexdocs, :oauth_client_id), + client_secret: Application.get_env(:hexdocs, :oauth_client_secret) + ] + end +end diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index 24e94ca..b939288 100644 --- a/lib/hexdocs/plug.ex +++ b/lib/hexdocs/plug.ex @@ -3,9 +3,8 @@ defmodule Hexdocs.Plug do use Plug.ErrorHandler require Logger - @key_html_fresh_time 60 - @key_asset_fresh_time 120 - @key_lifetime 60 * 60 * 24 * 29 + # OAuth token refresh buffer - refresh token 5 minutes before expiry + @token_refresh_buffer 5 * 60 use Sentry.PlugCapture @@ -43,7 +42,10 @@ defmodule Hexdocs.Plug do key: "_hexdocs_key", signing_salt: {Application, :get_env, [:hexdocs, :session_signing_salt]}, encryption_salt: {Application, :get_env, [:hexdocs, :session_encryption_salt]}, - max_age: 60 * 60 * 24 * 30 + max_age: 60 * 60 * 24 * 30, + secure: Mix.env() == :prod, + http_only: true, + same_site: "Lax" ) plug(:put_secret_key_base) @@ -62,91 +64,205 @@ defmodule Hexdocs.Plug do !subdomain -> send_resp(conn, 400, "") - key = conn.query_params["key"] -> - update_key(conn, key) + # OAuth callback - exchange code for tokens + conn.request_path == "/oauth/callback" -> + handle_oauth_callback(conn, subdomain) - key = get_session(conn, "key") -> - try_serve_page(conn, subdomain, key) + # OAuth access token in session + access_token = get_session(conn, "access_token") -> + try_serve_page_oauth(conn, subdomain, access_token) true -> - redirect_hexpm(conn, subdomain) + redirect_oauth(conn, subdomain) end end - defp try_serve_page(conn, organization, key) do - created_at = get_session(conn, "key_created_at") - refreshed_at = get_session(conn, "key_refreshed_at") + defp redirect_oauth(conn, organization) do + code_verifier = Hexdocs.OAuth.generate_code_verifier() + code_challenge = Hexdocs.OAuth.generate_code_challenge(code_verifier) + state = Hexdocs.OAuth.generate_state() - if key_live?(created_at) do - if key_fresh?(refreshed_at, conn.path_info) do - serve_page(conn, organization) - else - serve_if_valid(conn, organization, key) - end - else - redirect_hexpm(conn, organization) + redirect_uri = build_oauth_redirect_uri(conn, organization) + + url = + Hexdocs.OAuth.authorization_url( + hexpm_url: Application.get_env(:hexdocs, :hexpm_url), + client_id: Application.get_env(:hexdocs, :oauth_client_id), + redirect_uri: redirect_uri, + scope: "docs:#{organization}", + state: state, + code_challenge: code_challenge + ) + + conn + |> put_session("oauth_code_verifier", code_verifier) + |> put_session("oauth_state", state) + |> put_session("oauth_return_path", conn.request_path) + |> redirect(url) + end + + defp build_oauth_redirect_uri(_conn, organization) do + scheme = if Mix.env() == :prod, do: "https", else: "http" + host = Application.get_env(:hexdocs, :host) + "#{scheme}://#{organization}.#{host}/oauth/callback" + end + + defp handle_oauth_callback(conn, organization) do + code = conn.query_params["code"] + state = conn.query_params["state"] + error = conn.query_params["error"] + stored_state = get_session(conn, "oauth_state") + code_verifier = get_session(conn, "oauth_code_verifier") + return_path = get_session(conn, "oauth_return_path") || "/" + + cond do + error -> + # User denied authorization or other OAuth error + error_description = conn.query_params["error_description"] || error + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: error_description)) + + is_nil(state) or state != stored_state -> + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: "Invalid OAuth state")) + + is_nil(code) -> + send_resp(conn, 400, Hexdocs.Templates.auth_error(reason: "Missing authorization code")) + + true -> + exchange_oauth_code(conn, code, code_verifier, organization, return_path) end end - defp redirect_hexpm(conn, organization) do - hexpm_url = Application.get_env(:hexdocs, :hexpm_url) - url = "#{hexpm_url}/login?hexdocs=#{organization}&return=#{conn.request_path}" - redirect(conn, url) + defp exchange_oauth_code(conn, code, code_verifier, organization, return_path) do + redirect_uri = build_oauth_redirect_uri(conn, organization) + + opts = + Hexdocs.OAuth.config() + |> Keyword.put(:redirect_uri, redirect_uri) + + case Hexdocs.OAuth.exchange_code(code, code_verifier, opts) do + {:ok, tokens} -> + conn + |> delete_session("oauth_code_verifier") + |> delete_session("oauth_state") + |> delete_session("oauth_return_path") + |> store_oauth_tokens(tokens) + |> redirect(return_path) + + {:error, {_status, %{"error_description" => description}}} -> + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: description)) + + {:error, {_status, %{"error" => error}}} -> + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: error)) + + {:error, reason} -> + Logger.error("OAuth code exchange failed: #{inspect(reason)}") + send_resp(conn, 500, Hexdocs.Templates.auth_error(reason: "Authentication failed")) + end end - defp subdomain(host) do - app_host = Application.get_env(:hexdocs, :host) + defp store_oauth_tokens(conn, tokens) do + now = NaiveDateTime.utc_now() + expires_in = tokens["expires_in"] || 1800 + expires_at = NaiveDateTime.add(now, expires_in, :second) - case String.split(host, ".", parts: 2) do - [subdomain, ^app_host] -> subdomain - _ -> nil + conn + |> put_session("access_token", tokens["access_token"]) + |> put_session("refresh_token", tokens["refresh_token"]) + |> put_session("token_expires_at", expires_at) + |> put_session("token_created_at", now) + end + + defp try_serve_page_oauth(conn, organization, access_token) do + expires_at = get_session(conn, "token_expires_at") + refresh_token = get_session(conn, "refresh_token") + + cond do + # Token needs refresh + token_needs_refresh?(expires_at) and refresh_token -> + case refresh_oauth_token(conn, refresh_token, organization) do + {:ok, conn, new_access_token} -> + serve_if_valid_oauth(conn, organization, new_access_token) + + {:error, _reason} -> + # Refresh failed, re-authenticate + redirect_oauth(conn, organization) + end + + # Token expired and no refresh token + token_expired?(expires_at) -> + redirect_oauth(conn, organization) + + # Token is valid, serve the page + true -> + serve_if_valid_oauth(conn, organization, access_token) end end - defp key_fresh?(timestamp, path_info) do - file = List.last(path_info) - lifetime = file_lifetime(file) - NaiveDateTime.diff(NaiveDateTime.utc_now(), timestamp) <= lifetime + defp token_needs_refresh?(nil), do: true + + defp token_needs_refresh?(expires_at) do + now = NaiveDateTime.utc_now() + diff = NaiveDateTime.diff(expires_at, now) + diff <= @token_refresh_buffer + end + + defp token_expired?(nil), do: true + + defp token_expired?(expires_at) do + NaiveDateTime.compare(NaiveDateTime.utc_now(), expires_at) == :gt end - defp key_live?(timestamp) do - NaiveDateTime.diff(NaiveDateTime.utc_now(), timestamp) <= @key_lifetime + defp refresh_oauth_token(conn, refresh_token, _organization) do + opts = Hexdocs.OAuth.config() + + case Hexdocs.OAuth.refresh_token(refresh_token, opts) do + {:ok, tokens} -> + conn = store_oauth_tokens(conn, tokens) + {:ok, conn, tokens["access_token"]} + + {:error, reason} -> + Logger.warning("OAuth token refresh failed: #{inspect(reason)}") + {:error, reason} + end end - defp serve_if_valid(conn, organization, key) do - case Hexdocs.Hexpm.verify_key(key, organization) do + defp serve_if_valid_oauth(conn, organization, access_token) do + case Hexdocs.Hexpm.verify_key(access_token, organization) do :ok -> - conn - |> put_session("key_refreshed_at", NaiveDateTime.utc_now()) - |> serve_page(organization) + serve_page(conn, organization) :refresh -> - redirect_hexpm(conn, organization) + # Token was rejected, try to refresh or re-authenticate + refresh_token = get_session(conn, "refresh_token") + + if refresh_token do + case refresh_oauth_token(conn, refresh_token, organization) do + {:ok, conn, new_access_token} -> + # Retry verification with new token + case Hexdocs.Hexpm.verify_key(new_access_token, organization) do + :ok -> serve_page(conn, organization) + _ -> redirect_oauth(conn, organization) + end + + {:error, _} -> + redirect_oauth(conn, organization) + end + else + redirect_oauth(conn, organization) + end {:error, message} -> send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: message)) end end - defp file_lifetime(file) do - if Path.extname(file || "") in ["", ".html"] do - @key_html_fresh_time - else - @key_asset_fresh_time - end - end - - defp update_key(conn, key) do - now = NaiveDateTime.utc_now() - - params = Map.delete(conn.query_params, "key") - path = conn.request_path <> Plug.Conn.Query.encode(params) + defp subdomain(host) do + app_host = Application.get_env(:hexdocs, :host) - conn - |> put_session("key", key) - |> put_session("key_refreshed_at", now) - |> put_session("key_created_at", now) - |> redirect(path) + case String.split(host, ".", parts: 2) do + [subdomain, ^app_host] -> subdomain + _ -> nil + end end defp serve_page(conn, organization) do diff --git a/test/hexdocs/oauth_test.exs b/test/hexdocs/oauth_test.exs new file mode 100644 index 0000000..f511be1 --- /dev/null +++ b/test/hexdocs/oauth_test.exs @@ -0,0 +1,154 @@ +defmodule Hexdocs.OAuthTest do + use ExUnit.Case, async: true + + alias Hexdocs.OAuth + + describe "generate_code_verifier/0" do + test "generates a non-empty string" do + verifier = OAuth.generate_code_verifier() + + assert is_binary(verifier) + assert String.length(verifier) > 0 + end + + test "generates unique values" do + verifier1 = OAuth.generate_code_verifier() + verifier2 = OAuth.generate_code_verifier() + + assert verifier1 != verifier2 + end + + test "generates URL-safe base64 encoded string" do + verifier = OAuth.generate_code_verifier() + + # Should not contain URL-unsafe characters + refute String.contains?(verifier, "+") + refute String.contains?(verifier, "/") + refute String.contains?(verifier, "=") + end + + test "generates 43-character string (32 bytes base64url encoded)" do + verifier = OAuth.generate_code_verifier() + + # 32 bytes base64url encoded without padding = 43 characters + assert String.length(verifier) == 43 + end + end + + describe "generate_code_challenge/1" do + test "generates a non-empty string" do + verifier = OAuth.generate_code_verifier() + challenge = OAuth.generate_code_challenge(verifier) + + assert is_binary(challenge) + assert String.length(challenge) > 0 + end + + test "generates URL-safe base64 encoded string" do + verifier = OAuth.generate_code_verifier() + challenge = OAuth.generate_code_challenge(verifier) + + refute String.contains?(challenge, "+") + refute String.contains?(challenge, "/") + refute String.contains?(challenge, "=") + end + + test "produces consistent output for same input" do + verifier = OAuth.generate_code_verifier() + challenge1 = OAuth.generate_code_challenge(verifier) + challenge2 = OAuth.generate_code_challenge(verifier) + + assert challenge1 == challenge2 + end + + test "produces different output for different inputs" do + verifier1 = OAuth.generate_code_verifier() + verifier2 = OAuth.generate_code_verifier() + + challenge1 = OAuth.generate_code_challenge(verifier1) + challenge2 = OAuth.generate_code_challenge(verifier2) + + assert challenge1 != challenge2 + end + + test "produces correct SHA-256 hash" do + # Known test vector + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + expected_challenge = + :crypto.hash(:sha256, verifier) + |> Base.url_encode64(padding: false) + + assert OAuth.generate_code_challenge(verifier) == expected_challenge + end + end + + describe "generate_state/0" do + test "generates a non-empty string" do + state = OAuth.generate_state() + + assert is_binary(state) + assert String.length(state) > 0 + end + + test "generates unique values" do + state1 = OAuth.generate_state() + state2 = OAuth.generate_state() + + assert state1 != state2 + end + end + + describe "authorization_url/1" do + test "builds correct authorization URL" do + url = + OAuth.authorization_url( + hexpm_url: "https://hex.pm", + client_id: "hexdocs", + redirect_uri: "https://acme.hexdocs.pm/oauth/callback", + scope: "docs:acme", + state: "random_state", + code_challenge: "challenge123" + ) + + assert String.starts_with?(url, "https://hex.pm/oauth/authorize?") + + uri = URI.parse(url) + query = URI.decode_query(uri.query) + + assert query["response_type"] == "code" + assert query["client_id"] == "hexdocs" + assert query["redirect_uri"] == "https://acme.hexdocs.pm/oauth/callback" + assert query["scope"] == "docs:acme" + assert query["state"] == "random_state" + assert query["code_challenge"] == "challenge123" + assert query["code_challenge_method"] == "S256" + end + + test "properly encodes special characters in parameters" do + url = + OAuth.authorization_url( + hexpm_url: "https://hex.pm", + client_id: "client with spaces", + redirect_uri: "https://example.com/callback?foo=bar", + scope: "docs:org", + state: "state&with=special", + code_challenge: "abc123" + ) + + # URL should be properly encoded + assert String.contains?(url, "client+with+spaces") or + String.contains?(url, "client%20with%20spaces") + end + end + + describe "config/0" do + test "returns keyword list with expected keys" do + config = OAuth.config() + + assert Keyword.has_key?(config, :hexpm_url) + assert Keyword.has_key?(config, :client_id) + assert Keyword.has_key?(config, :client_secret) + end + end +end diff --git a/test/hexdocs/plug_test.exs b/test/hexdocs/plug_test.exs index 39809b7..b04cca0 100644 --- a/test/hexdocs/plug_test.exs +++ b/test/hexdocs/plug_test.exs @@ -14,182 +14,284 @@ defmodule Hexdocs.PlugTest do assert conn.status == 400 end - test "redirect to hexpm with no session and no key" do - conn = conn(:get, "http://plugtest.localhost:5002/foo") |> call() - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/foo"] - end - - test "handle no path" do - conn = conn(:get, "http://plugtest.localhost:5002/") |> call() - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/"] - end - - test "update session and redirect when key is set" do - conn = conn(:get, "http://plugtest.localhost:5002/foo?key=abc") |> call() - assert conn.status == 302 - assert get_resp_header(conn, "location") == ["/foo"] - - assert get_session(conn, "key") == "abc" - assert recent?(get_session(conn, "key_refreshed_at")) - assert recent?(get_session(conn, "key_created_at")) - end - - test "redirect to hexpm with dead key" do - old = ~N[2018-01-01 00:00:00] - - conn = - conn(:get, "http://plugtest.localhost:5002/foo") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/foo"] - end - - test "reverify stale key succeeds", %{test: test} do - Mox.expect(HexpmMock, :verify_key, fn key, organization -> - assert key == "abc" - assert organization == "plugtest" - :ok - end) - - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600) - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "body" - end - - test "reverify stale key requires refresh and redirects", %{test: test} do - Mox.expect(HexpmMock, :verify_key, fn key, organization -> - assert key == "abc" - assert organization == "plugtest" - :refresh - end) - - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600) - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/foo") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/foo"] - end - - test "reverify stale key fails" do - Mox.expect(HexpmMock, :verify_key, fn key, organization -> - assert key == "abc" - assert organization == "plugtest" - {:error, "account not authorized"} - end) - - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600) - - conn = - conn(:get, "http://plugtest.localhost:5002/foo") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 403 - assert conn.resp_body =~ "account not authorized" - end - - test "serve 200 page", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "body" + describe "OAuth flow" do + test "redirect to OAuth authorize with no session" do + conn = conn(:get, "http://plugtest.localhost:5002/foo") |> call() + assert conn.status == 302 + + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?") + + uri = URI.parse(location) + query = URI.decode_query(uri.query) + + assert query["response_type"] == "code" + assert query["client_id"] == "hexdocs" + assert query["scope"] == "docs:plugtest" + assert query["code_challenge_method"] == "S256" + assert query["state"] != nil + assert query["code_challenge"] != nil + + # Should store PKCE verifier and state in session + assert get_session(conn, "oauth_code_verifier") != nil + assert get_session(conn, "oauth_state") != nil + assert get_session(conn, "oauth_return_path") == "/foo" + end + + test "OAuth callback with invalid state returns error" do + conn = + conn(:get, "http://plugtest.localhost:5002/oauth/callback?code=abc&state=wrong") + |> init_test_session(%{ + "oauth_state" => "correct_state", + "oauth_code_verifier" => "verifier" + }) + |> call() + + assert conn.status == 403 + assert conn.resp_body =~ "Invalid OAuth state" + end + + test "OAuth callback with missing code returns error" do + conn = + conn(:get, "http://plugtest.localhost:5002/oauth/callback?state=correct_state") + |> init_test_session(%{ + "oauth_state" => "correct_state", + "oauth_code_verifier" => "verifier" + }) + |> call() + + assert conn.status == 400 + assert conn.resp_body =~ "Missing authorization code" + end + + test "OAuth callback with error parameter returns error" do + conn = + conn( + :get, + "http://plugtest.localhost:5002/oauth/callback?error=access_denied&error_description=User%20denied" + ) + |> init_test_session(%{"oauth_state" => "state", "oauth_code_verifier" => "verifier"}) + |> call() + + assert conn.status == 403 + assert conn.resp_body =~ "User denied" + end + + test "serve page with valid OAuth token", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + + test "redirect to OAuth when token expired and no refresh token" do + now = NaiveDateTime.utc_now() + expired = NaiveDateTime.add(now, -1800, :second) + + conn = + conn(:get, "http://plugtest.localhost:5002/foo") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "token_expires_at" => expired, + "token_created_at" => NaiveDateTime.add(expired, -1800, :second) + }) + |> call() + + assert conn.status == 302 + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?") + end end - test "serve 404 page", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/404.html") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 404 - assert conn.resp_body =~ "Page not found" - end - - test "redirect to root", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 302 - assert get_resp_header(conn, "location") == ["/#{test}/"] - end - - test "serve index.html for root requests", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "body" - end - - test "serve docs_config.js for unversioned and versioned requests", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/docs_config.js", "var versionNodes;") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/docs_config.js") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "var versionNodes;" - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/1.0.0/docs_config.js") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "var versionNodes;" + describe "page serving with OAuth" do + test "serve 200 page", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + + test "serve 404 page", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/404.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 404 + assert conn.resp_body =~ "Page not found" + end + + test "redirect to root", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 302 + assert get_resp_header(conn, "location") == ["/#{test}/"] + end + + test "serve index.html for root requests", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + + test "serve docs_config.js for unversioned and versioned requests", %{test: test} do + Mox.expect(HexpmMock, :verify_key, 2, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/docs_config.js", "var versionNodes;") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/docs_config.js") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "var versionNodes;" + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/1.0.0/docs_config.js") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "var versionNodes;" + end + + test "token verification fails redirects to OAuth" do + Mox.expect(HexpmMock, :verify_key, fn _token, _organization -> + {:error, "account not authorized"} + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + + conn = + conn(:get, "http://plugtest.localhost:5002/foo") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 403 + assert conn.resp_body =~ "account not authorized" + end + + test "handle no path redirects to OAuth" do + conn = conn(:get, "http://plugtest.localhost:5002/") |> call() + assert conn.status == 302 + + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?") + end end defp call(conn) do Hexdocs.Plug.call(conn, []) end - - defp recent?(datetime) do - abs(NaiveDateTime.diff(datetime, NaiveDateTime.utc_now())) < 3 - end end From 9ab9be8ed8c3221430c71455808405ce3ce61b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 18 Dec 2025 00:14:53 +0100 Subject: [PATCH 2/3] ex_aws config --- config/config.exs | 4 ++++ config/prod.exs | 3 --- lib/hexdocs/http.ex | 8 ++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index e200cf0..086b69e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -41,6 +41,10 @@ config :hexdocs, :docs_private_bucket, name: "hexdocs-private-staging" config :hexdocs, :docs_public_bucket, name: "hexdocs-public-staging" +config :ex_aws, + http_client: ExAws.Request.Hackney, + json_codec: Jason + config :logger, :console, format: "[$level] $metadata$message\n" import_config "#{Mix.env()}.exs" diff --git a/config/prod.exs b/config/prod.exs index a03ba9b..cd7fe75 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -14,9 +14,6 @@ config :hexdocs, :docs_private_bucket, implementation: Hexdocs.Store.GS config :hexdocs, :docs_public_bucket, implementation: Hexdocs.Store.GS -config :ex_aws, - json_codec: Jason - config :sentry, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index 3148a1d..b7feafc 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -24,8 +24,12 @@ defmodule Hexdocs.HTTP do end def post(url, headers, body, opts \\ []) do - :hackney.post(url, headers, body, opts) - |> read_response() + if :with_body in opts do + :hackney.post(url, headers, body, opts) + else + :hackney.post(url, headers, body, opts) + |> read_response() + end end def delete(url, headers, opts \\ []) do From 00750fdeee202ffdbcdd8d4b997e4faa3144d678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 18 Dec 2025 00:30:42 +0100 Subject: [PATCH 3/3] HTTP cleanup --- lib/hexdocs/http.ex | 12 ++++-------- lib/hexdocs/search/typesense.ex | 2 +- lib/hexdocs/source_repo/github.ex | 5 ++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index b7feafc..7aa5a68 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -8,8 +8,8 @@ defmodule Hexdocs.HTTP do :hackney.head(url, headers) end - def get(url, headers) do - :hackney.get(url, headers) + def get(url, headers, opts \\ []) do + :hackney.get(url, headers, "", opts) |> read_response() end @@ -24,12 +24,8 @@ defmodule Hexdocs.HTTP do end def post(url, headers, body, opts \\ []) do - if :with_body in opts do - :hackney.post(url, headers, body, opts) - else - :hackney.post(url, headers, body, opts) - |> read_response() - end + :hackney.post(url, headers, body, opts) + |> read_response() end def delete(url, headers, opts \\ []) do diff --git a/lib/hexdocs/search/typesense.ex b/lib/hexdocs/search/typesense.ex index 37782e4..4e189ba 100644 --- a/lib/hexdocs/search/typesense.ex +++ b/lib/hexdocs/search/typesense.ex @@ -29,7 +29,7 @@ defmodule Hexdocs.Search.Typesense do url = url("collections/#{collection()}/documents/import?action=create") headers = [{"x-typesense-api-key", api_key()}] - case HTTP.post(url, headers, ndjson, [:with_body, recv_timeout: @timeout]) do + case HTTP.post(url, headers, ndjson, recv_timeout: @timeout) do {:ok, 200, _resp_headers, ndjson} -> ndjson |> String.split("\n") diff --git a/lib/hexdocs/source_repo/github.ex b/lib/hexdocs/source_repo/github.ex index 36ec6ce..5842e63 100644 --- a/lib/hexdocs/source_repo/github.ex +++ b/lib/hexdocs/source_repo/github.ex @@ -9,16 +9,15 @@ defmodule Hexdocs.SourceRepo.GitHub do url = @github_url <> "/repos/#{repo}/tags" headers = [ - accept: "application/json" + {"accept", "application/json"} ] options = [ - :with_body, basic_auth: {user, token} ] Hexdocs.HTTP.retry("github", url, fn -> - :hackney.get(url, headers, "", options) + Hexdocs.HTTP.get(url, headers, options) end) |> case do {:ok, 200, _headers, body} ->