Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -38,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"
3 changes: 0 additions & 3 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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!()],
Expand Down
2 changes: 2 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
14 changes: 12 additions & 2 deletions lib/hexdocs/hexpm/impl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions lib/hexdocs/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
186 changes: 186 additions & 0 deletions lib/hexdocs/oauth.ex
Original file line number Diff line number Diff line change
@@ -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
Loading