diff --git a/config/config.exs b/config/config.exs index d926540..3d1c8b2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,9 +1,13 @@ import Config config :hexdocs, + scheme: "http", 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", @@ -38,6 +42,12 @@ 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.Req, + json_codec: JSON + +config :sentry, client: Sentry.FinchClient + 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..8ce97ba 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,6 +1,7 @@ import Config config :hexdocs, + scheme: "https", hexpm_impl: Hexdocs.Hexpm.Impl, store_impl: Hexdocs.Store.Impl, cdn_impl: Hexdocs.CDN.Fastly, @@ -14,9 +15,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/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/cdn/fastly.ex b/lib/hexdocs/cdn/fastly.ex index eaf2b84..0a4f72d 100644 --- a/lib/hexdocs/cdn/fastly.ex +++ b/lib/hexdocs/cdn/fastly.ex @@ -29,20 +29,18 @@ defmodule Hexdocs.CDN.Fastly do url = @fastly_url <> url headers = [ - "fastly-key": auth(), - accept: "application/json", - "content-type": "application/json" + {"fastly-key", auth()}, + {"accept", "application/json"}, + {"content-type", "application/json"} ] body = JSON.encode!(body) - Hexdocs.HTTP.retry("fastly", url, fn -> :hackney.post(url, headers, body, []) end) - |> read_body() + Hexdocs.HTTP.retry("fastly", url, fn -> Hexdocs.HTTP.post(url, headers, body) end) + |> decode_body() end - defp read_body({:ok, status, headers, client}) do - {:ok, body} = :hackney.body(client) - + defp decode_body({:ok, status, headers, body}) do body = case JSON.decode(body) do {:ok, map} -> map 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..93d56e8 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -5,61 +5,94 @@ defmodule Hexdocs.HTTP do require Logger def head(url, headers) do - :hackney.head(url, headers) + case Req.head(url, headers: headers, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers)} + + {:error, reason} -> + {:error, reason} + end end - def get(url, headers) do - :hackney.get(url, headers) - |> read_response() + def get(url, headers, _opts \\ []) do + case Req.get(url, headers: headers, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} + + {:error, reason} -> + {:error, reason} + end end def get_stream(url, headers) do - :hackney.get(url, headers) - |> stream_response() + case Req.get(url, headers: headers, retry: false, decode_body: false, into: :self) do + {:ok, response} -> + stream = stream_body(response.body) + {:ok, response.status, normalize_headers(response.headers), stream} + + {:error, reason} -> + {:error, reason} + end end def put(url, headers, body) do - :hackney.put(url, headers, body, recv_timeout: 10_000) - |> read_response() - end + case Req.put(url, + headers: headers, + body: body, + retry: false, + decode_body: false, + receive_timeout: 10_000 + ) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} - def post(url, headers, body, opts \\ []) do - :hackney.post(url, headers, body, opts) + {:error, reason} -> + {:error, reason} + end end - def delete(url, headers, opts \\ []) do - :hackney.delete(url, headers, "", opts) - |> read_response() + def post(url, headers, body, _opts \\ []) do + case Req.post(url, headers: headers, body: body, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} + + {:error, reason} -> + {:error, reason} + end end - defp read_response(result) do - with {:ok, status, headers, ref} <- result, - {:ok, body} <- :hackney.body(ref) do - {:ok, status, headers, body} + def delete(url, headers, _opts \\ []) do + case Req.delete(url, headers: headers, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} + + {:error, reason} -> + {:error, reason} end end - defp stream_response({:ok, status, headers, ref}) do + defp normalize_headers(headers) do + Enum.map(headers, fn {name, values} -> {name, Enum.join(values, ", ")} end) + end + + defp stream_body(ref) do start_fun = fn -> :cont end after_fun = fn _ -> :ok end next_fun = fn :cont -> - case :hackney.stream_body(ref) do - {:ok, data} -> {[{:ok, data}], :cont} - :done -> {:halt, :ok} - {:error, reason} -> {[{:error, reason}], :stop} + receive do + {^ref, {:data, data}} -> {[{:ok, data}], :cont} + {^ref, :done} -> {:halt, :ok} + after + 30_000 -> {[{:error, :timeout}], :stop} end :stop -> {:halt, :ok} end - {:ok, status, headers, Stream.resource(start_fun, next_fun, after_fun)} - end - - defp stream_response(other) do - other + Stream.resource(start_fun, next_fun, after_fun) end def retry(service, url, fun) do diff --git a/lib/hexdocs/oauth.ex b/lib/hexdocs/oauth.ex new file mode 100644 index 0000000..90af172 --- /dev/null +++ b/lib/hexdocs/oauth.ex @@ -0,0 +1,191 @@ +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 = + %{ + "grant_type" => "authorization_code", + "code" => code, + "redirect_uri" => redirect_uri, + "client_id" => client_id, + "client_secret" => client_secret, + "code_verifier" => code_verifier + } + |> maybe_put("name", opts[:name]) + |> JSON.encode!() + + 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, JSON.decode!(response_body)} + + {:ok, status, _headers, response_body} -> + {:error, {status, JSON.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 = + JSON.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, JSON.decode!(response_body)} + + {:ok, status, _headers, response_body} -> + {:error, {status, JSON.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 + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index 24e94ca..10d8a49 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,206 @@ 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 = Application.get_env(:hexdocs, :scheme) + 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) + |> Keyword.put(:name, organization) + + 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/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} -> diff --git a/mix.exs b/mix.exs index 02bc8eb..c9145cf 100644 --- a/mix.exs +++ b/mix.exs @@ -42,7 +42,7 @@ defmodule Hexdocs.MixProject do {:ex_aws_s3, "~> 2.0"}, {:ex_aws_sqs, "~> 3.0"}, {:goth, "~> 1.0"}, - {:hackney, "~> 1.13"}, + {:req, "~> 0.5.0"}, {:logster, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, {:sentry, "~> 11.0"}, diff --git a/mix.lock b/mix.lock index 42b860d..5437ed8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,6 @@ %{ "broadway": {:hex, :broadway, "1.2.1", "83a1567423c26885e15f6cd8670ca790370af2fcff2ede7fa88c5ea793087a67", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68ae63d83b55bdca0f95cd49feee5fb74c5a6bec557caf940860fe07dbc8a4fb"}, "broadway_sqs": {:hex, :broadway_sqs, "0.7.4", "ab89b298f9253adb8534f92095b56d4879e35fe2f5a0730256f7e824572c637f", [:mix], [{:broadway, "~> 1.0", [hex: :broadway, repo: "hexpm", optional: false]}, {:ex_aws_sqs, "~> 3.2.1 or ~> 3.3", [hex: :ex_aws_sqs, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:saxy, "~> 1.1", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7140085c4f7c4b27886b3a8f3d0942976f39f195fdbc2f652c5d7b157f93ae28"}, - "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, @@ -11,30 +10,25 @@ "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, - "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hex_core": {:hex, :hex_core, "0.11.0", "d1c6bbf2a4ee6b5f002bec6fa52b5080c53c8b63b7caf6eb88b943687547bff4", [:rebar3], [], "hexpm", "707893677a425491962a2db522f1d2b1f85f97ea27418b06f7929f1d30cde0b0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "logster": {:hex, :logster, "1.1.1", "d6fddac540dd46adde0c894024500867fe63b0043713f842c62da5815e21db10", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d18e852c430812ad1c9756998ebe46ec814c724e6eb551a512d7e3f8dee24cef"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"}, "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } 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 diff --git a/test/hexdocs/search_test.exs b/test/hexdocs/search_test.exs index 0b784a0..a297730 100644 --- a/test/hexdocs/search_test.exs +++ b/test/hexdocs/search_test.exs @@ -337,10 +337,12 @@ defmodule Hexdocs.SearchTest do headers = [{"x-typesense-api-key", api_key}, {"content-type", "application/json"}] payload = JSON.encode_to_iodata!(Typesense.collection_schema(collection)) - assert {:ok, 201, _resp_headers, _ref} = - :hackney.post("http://localhost:8108/collections", headers, payload) + assert {:ok, 201, _resp_headers, _body} = + Hexdocs.HTTP.post("http://localhost:8108/collections", headers, payload) - on_exit(fn -> :hackney.delete("http://localhost:8108/collections/#{collection}", headers) end) + on_exit(fn -> + Hexdocs.HTTP.delete("http://localhost:8108/collections/#{collection}", headers) + end) end defp typesense_search(query) do @@ -352,8 +354,7 @@ defmodule Hexdocs.SearchTest do URI.encode_query(query) headers = [{"x-typesense-api-key", api_key}] - assert {:ok, 200, _resp_headers, ref} = :hackney.get(url, headers) - assert {:ok, body} = :hackney.body(ref) + assert {:ok, 200, _resp_headers, body} = Hexdocs.HTTP.get(url, headers) assert %{"hits" => hits} = JSON.decode!(body) hits end