Skip to content

Commit a41d59d

Browse files
committed
Use OAuth
1 parent 8326867 commit a41d59d

File tree

8 files changed

+806
-232
lines changed

8 files changed

+806
-232
lines changed

config/config.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ config :hexdocs,
44
port: "4002",
55
hexpm_url: "http://localhost:4000",
66
hexpm_secret: "2cd6d09334d4b00a2be4d532342b799b",
7+
# OAuth client credentials for hexpm integration
8+
oauth_client_id: "hexdocs",
9+
oauth_client_secret: "dev_secret_for_testing",
710
typesense_url: "http://localhost:8108",
811
typesense_api_key: "hexdocs",
912
typesense_collection: "hexdocs",

config/runtime.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ if config_env() == :prod do
55
port: System.fetch_env!("HEXDOCS_PORT"),
66
hexpm_url: System.fetch_env!("HEXDOCS_HEXPM_URL"),
77
hexpm_secret: System.fetch_env!("HEXDOCS_HEXPM_SECRET"),
8+
oauth_client_id: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_ID"),
9+
oauth_client_secret: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_SECRET"),
810
typesense_url: System.fetch_env!("HEXDOCS_TYPESENSE_URL"),
911
typesense_api_key: System.fetch_env!("HEXDOCS_TYPESENSE_API_KEY"),
1012
typesense_collection: System.fetch_env!("HEXDOCS_TYPESENSE_COLLECTION"),

lib/hexdocs/hexpm/impl.ex

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,20 @@ defmodule Hexdocs.Hexpm.Impl do
5656
Application.get_env(:hexdocs, :hexpm_url) <> path
5757
end
5858

59-
defp headers(key) do
59+
defp headers(key_or_token) do
60+
# Support both legacy API keys and OAuth Bearer tokens
61+
# OAuth tokens are JWTs that start with "eyJ" (base64 of '{"')
62+
# Legacy API keys are shorter hex strings
63+
authorization =
64+
if String.starts_with?(key_or_token, "eyJ") do
65+
"Bearer #{key_or_token}"
66+
else
67+
key_or_token
68+
end
69+
6070
[
6171
{"accept", "application/json"},
62-
{"authorization", key}
72+
{"authorization", authorization}
6373
]
6474
end
6575
end

lib/hexdocs/http.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ defmodule Hexdocs.HTTP do
2525

2626
def post(url, headers, body, opts \\ []) do
2727
:hackney.post(url, headers, body, opts)
28+
|> read_response()
2829
end
2930

3031
def delete(url, headers, opts \\ []) do

lib/hexdocs/oauth.ex

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
defmodule Hexdocs.OAuth do
2+
@moduledoc """
3+
OAuth 2.0 Authorization Code with PKCE client for hexdocs.
4+
5+
This module implements the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for
6+
Code Exchange) as defined in RFC 7636. It can be used by any application integrating
7+
with hexpm's OAuth infrastructure.
8+
9+
## Flow
10+
11+
1. Generate code_verifier and code_challenge using `generate_code_verifier/0` and
12+
`generate_code_challenge/1`
13+
2. Build authorization URL with `authorization_url/1` and redirect user
14+
3. After user authorizes, exchange the code for tokens with `exchange_code/3`
15+
4. Use `refresh_token/2` to get new access tokens before expiration
16+
"""
17+
18+
@doc """
19+
Generate a cryptographically random code_verifier for PKCE.
20+
21+
Returns a 43-character URL-safe base64 string (32 random bytes encoded).
22+
"""
23+
def generate_code_verifier do
24+
:crypto.strong_rand_bytes(32)
25+
|> Base.url_encode64(padding: false)
26+
end
27+
28+
@doc """
29+
Generate code_challenge from code_verifier using S256 method.
30+
31+
Computes SHA-256 hash of the verifier and base64url encodes it.
32+
"""
33+
def generate_code_challenge(verifier) do
34+
:crypto.hash(:sha256, verifier)
35+
|> Base.url_encode64(padding: false)
36+
end
37+
38+
@doc """
39+
Generate a random state parameter for CSRF protection.
40+
"""
41+
def generate_state do
42+
:crypto.strong_rand_bytes(16)
43+
|> Base.url_encode64(padding: false)
44+
end
45+
46+
@doc """
47+
Build the OAuth authorization URL with PKCE parameters.
48+
49+
## Options (all required)
50+
51+
* `:hexpm_url` - Base URL of hexpm (e.g., "https://hex.pm")
52+
* `:client_id` - OAuth client ID
53+
* `:redirect_uri` - URI to redirect to after authorization
54+
* `:scope` - Space-separated scopes to request
55+
* `:state` - Random state for CSRF protection
56+
* `:code_challenge` - PKCE code challenge
57+
58+
"""
59+
def authorization_url(opts) do
60+
hexpm_url = Keyword.fetch!(opts, :hexpm_url)
61+
client_id = Keyword.fetch!(opts, :client_id)
62+
redirect_uri = Keyword.fetch!(opts, :redirect_uri)
63+
scope = Keyword.fetch!(opts, :scope)
64+
state = Keyword.fetch!(opts, :state)
65+
code_challenge = Keyword.fetch!(opts, :code_challenge)
66+
67+
query =
68+
URI.encode_query(%{
69+
"response_type" => "code",
70+
"client_id" => client_id,
71+
"redirect_uri" => redirect_uri,
72+
"scope" => scope,
73+
"state" => state,
74+
"code_challenge" => code_challenge,
75+
"code_challenge_method" => "S256"
76+
})
77+
78+
"#{hexpm_url}/oauth/authorize?#{query}"
79+
end
80+
81+
@doc """
82+
Exchange an authorization code for access and refresh tokens.
83+
84+
## Parameters
85+
86+
* `code` - The authorization code received from the callback
87+
* `code_verifier` - The original code_verifier generated before authorization
88+
* `opts` - Keyword list with:
89+
* `:hexpm_url` - Base URL of hexpm
90+
* `:client_id` - OAuth client ID
91+
* `:client_secret` - OAuth client secret
92+
* `:redirect_uri` - The same redirect_uri used in authorization
93+
94+
## Returns
95+
96+
* `{:ok, tokens}` - Map with "access_token", "refresh_token", "expires_in", etc.
97+
* `{:error, reason}` - Error tuple with status code and error response
98+
"""
99+
def exchange_code(code, code_verifier, opts) do
100+
hexpm_url = Keyword.fetch!(opts, :hexpm_url)
101+
client_id = Keyword.fetch!(opts, :client_id)
102+
client_secret = Keyword.fetch!(opts, :client_secret)
103+
redirect_uri = Keyword.fetch!(opts, :redirect_uri)
104+
105+
body =
106+
Jason.encode!(%{
107+
"grant_type" => "authorization_code",
108+
"code" => code,
109+
"redirect_uri" => redirect_uri,
110+
"client_id" => client_id,
111+
"client_secret" => client_secret,
112+
"code_verifier" => code_verifier
113+
})
114+
115+
url = "#{hexpm_url}/api/oauth/token"
116+
headers = [{"content-type", "application/json"}]
117+
118+
case Hexdocs.HTTP.post(url, headers, body) do
119+
{:ok, status, _headers, response_body} when status in 200..299 ->
120+
{:ok, Jason.decode!(response_body)}
121+
122+
{:ok, status, _headers, response_body} ->
123+
{:error, {status, Jason.decode!(response_body)}}
124+
125+
{:error, reason} ->
126+
{:error, reason}
127+
end
128+
end
129+
130+
@doc """
131+
Refresh an access token using a refresh token.
132+
133+
## Parameters
134+
135+
* `refresh_token` - The refresh token from a previous token response
136+
* `opts` - Keyword list with:
137+
* `:hexpm_url` - Base URL of hexpm
138+
* `:client_id` - OAuth client ID
139+
* `:client_secret` - OAuth client secret
140+
141+
## Returns
142+
143+
* `{:ok, tokens}` - Map with new "access_token", "refresh_token", "expires_in", etc.
144+
* `{:error, reason}` - Error tuple
145+
"""
146+
def refresh_token(refresh_token, opts) do
147+
hexpm_url = Keyword.fetch!(opts, :hexpm_url)
148+
client_id = Keyword.fetch!(opts, :client_id)
149+
client_secret = Keyword.fetch!(opts, :client_secret)
150+
151+
body =
152+
Jason.encode!(%{
153+
"grant_type" => "refresh_token",
154+
"refresh_token" => refresh_token,
155+
"client_id" => client_id,
156+
"client_secret" => client_secret
157+
})
158+
159+
url = "#{hexpm_url}/api/oauth/token"
160+
headers = [{"content-type", "application/json"}]
161+
162+
case Hexdocs.HTTP.post(url, headers, body) do
163+
{:ok, status, _headers, response_body} when status in 200..299 ->
164+
{:ok, Jason.decode!(response_body)}
165+
166+
{:ok, status, _headers, response_body} ->
167+
{:error, {status, Jason.decode!(response_body)}}
168+
169+
{:error, reason} ->
170+
{:error, reason}
171+
end
172+
end
173+
174+
@doc """
175+
Get the OAuth configuration from application environment.
176+
177+
Returns a keyword list with all OAuth settings needed for API calls.
178+
"""
179+
def config do
180+
[
181+
hexpm_url: Application.get_env(:hexdocs, :hexpm_url),
182+
client_id: Application.get_env(:hexdocs, :oauth_client_id),
183+
client_secret: Application.get_env(:hexdocs, :oauth_client_secret)
184+
]
185+
end
186+
end

0 commit comments

Comments
 (0)