defmodule OpenAgents.GitHubOAuth do
@moduledoc "GitHub OAuth state, PKCE, code exchange, and identity projection."
alias OpenAgents.Accounts
alias OpenAgents.GitHubOAuth.RuntimeConfig
@default_authorize_url "https://github.com/login/oauth/authorize"
@default_token_url "https://github.com/login/oauth/access_token"
@default_user_url "https://api.github.com/user"
@default_api_url "https://api.github.com"
@default_attempt_ttl_seconds 600
@github_api_version "2022-11-28"
@user_agent "OpenAgents"
@requested_scopes ["user:email"]
@repository_scopes ["repo", "read:org"]
@type attempt :: %{required(String.t()) => String.t() | integer()}
def begin_authorization do
with {:ok, config} <- config(),
state <- random_url_token(),
verifier <- random_url_token(),
challenge <- pkce_challenge(verifier),
expires_at <- DateTime.add(DateTime.utc_now(), config.attempt_ttl_seconds, :second),
{:ok, receipt} <- Accounts.create_oauth_attempt(state, expires_at) do
attempt = %{
"id" => receipt.id,
"state" => state,
"verifier" => verifier,
"expires_at" => DateTime.to_unix(expires_at)
}
query =
URI.encode_query(%{
"client_id" => config.client_id,
"code_challenge" => challenge,
"code_challenge_method" => "S256",
"redirect_uri" => config.redirect_uri,
"scope" => oauth_scope(),
"state" => state
})
{:ok, attempt, config.authorize_url <> "?" <> query}
else
{:error, %Ecto.Changeset{}} -> {:error, :oauth_attempt_unavailable}
{:error, reason} -> {:error, reason}
end
end
def consume_attempt(
%{"id" => attempt_id, "state" => expected_state, "expires_at" => expires_at},
returned_state
)
when is_binary(attempt_id) and is_binary(expected_state) and is_integer(expires_at) and
is_binary(returned_state) do
with true <- DateTime.to_unix(DateTime.utc_now()) < expires_at,
true <- secure_equal?(expected_state, returned_state),
:ok <- Accounts.consume_oauth_attempt(attempt_id, returned_state) do
:ok
else
_invalid -> {:error, :invalid_oauth_state}
end
end
def consume_attempt(_attempt, _returned_state), do: {:error, :invalid_oauth_state}
def exchange_and_fetch(code, verifier) when is_binary(code) and is_binary(verifier) do
with :ok <- validate_code_and_verifier(code, verifier),
{:ok, config} <- config(),
{:ok, access_token, scopes} <- exchange_code(config, code, verifier),
{:ok, profile} <- fetch_profile(config, access_token) do
{:ok, profile, access_token, scopes}
end
end
def exchange_and_fetch(_code, _verifier), do: {:error, :invalid_oauth_callback}
@doc "Revokes one OAuth grant using the OAuth application's own credentials."
@spec revoke(String.t()) :: :ok | {:error, atom()}
def revoke(access_token)
when is_binary(access_token) and byte_size(access_token) in 1..512 do
with {:ok, config} <- config() do
request_options =
[
headers: api_headers(),
receive_timeout: 10_000,
retry: false
]
|> Keyword.merge(config.request_options)
|> Keyword.put(:auth, {:basic, config.client_id <> ":" <> config.client_secret})
|> Keyword.put(:json, %{"access_token" => access_token})
case Req.delete(config.revoke_url, request_options) do
{:ok, %Req.Response{status: 204}} ->
:ok
{:ok, %Req.Response{status: status}} when status in 400..599 ->
{:error, :revocation_rejected}
{:ok, %Req.Response{}} ->
{:error, :invalid_revocation_response}
{:error, _transport_error} ->
{:error, :github_unavailable}
end
end
end
def revoke(_access_token), do: {:error, :invalid_token}
@doc "The exact OAuth scopes requested during GitHub sign-in."
@spec requested_scopes() :: [String.t()]
def requested_scopes, do: @requested_scopes
@doc "The OAuth scopes required by the GitHub-backed namespace and import model."
@spec required_scopes() :: [String.t()]
def required_scopes, do: @repository_scopes
defp exchange_code(config, code, verifier) do
request_options =
[
headers: oauth_headers(),
receive_timeout: 10_000,
retry: false
]
|> Keyword.merge(config.request_options)
|> Keyword.put(:form,
client_id: config.client_id,
client_secret: config.client_secret,
code: code,
redirect_uri: config.redirect_uri,
code_verifier: verifier
)
case Req.post(config.token_url, request_options) do
{:ok,
%Req.Response{
status: status,
body: %{"access_token" => token, "scope" => granted_scope}
}}
when status in 200..299 and is_binary(token) and byte_size(token) > 0 and
is_binary(granted_scope) ->
with {:ok, scopes} <- validate_granted_scopes(granted_scope), do: {:ok, token, scopes}
{:ok, %Req.Response{status: status}} when status in 400..599 ->
{:error, :oauth_code_exchange_rejected}
{:ok, %Req.Response{}} ->
{:error, :invalid_oauth_token_response}
{:error, _transport_error} ->
{:error, :github_unavailable}
end
end
defp fetch_profile(config, access_token) do
request_options =
[
headers: api_headers(),
receive_timeout: 10_000,
retry: false
]
|> Keyword.merge(config.request_options)
|> Keyword.put(:auth, {:bearer, access_token})
case Req.get(config.user_url, request_options) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
parse_profile(body)
{:ok, %Req.Response{status: status}} when status in 400..599 ->
{:error, :github_profile_rejected}
{:ok, %Req.Response{}} ->
{:error, :invalid_github_profile_response}
{:error, _transport_error} ->
{:error, :github_unavailable}
end
end
defp parse_profile(%{"id" => id, "login" => login, "avatar_url" => avatar_url} = body)
when is_integer(id) and id > 0 and is_binary(login) and is_binary(avatar_url) do
name = parse_name(body)
profile = %{
github_id: id,
github_login: login,
github_name: name,
github_avatar_url: avatar_url
}
case OpenAgents.Accounts.User.github_changeset(%OpenAgents.Accounts.User{}, %{
github_id: id,
github_login: login,
github_name: name,
github_avatar_url: avatar_url,
last_authenticated_at: DateTime.utc_now()
}) do
%{valid?: true} -> {:ok, profile}
_invalid -> {:error, :invalid_github_profile}
end
end
defp parse_profile(_body), do: {:error, :invalid_github_profile}
defp parse_name(%{"name" => name}) when is_binary(name) do
case String.trim(name) do
"" -> nil
trimmed -> String.slice(trimmed, 0, 255)
end
end
defp parse_name(_body), do: nil
defp config do
settings = Application.get_env(:openagents, :github_oauth, [])
with :ok <- validate_scope_configuration(),
client_id when is_binary(client_id) and client_id != "" <- settings[:client_id],
client_secret when is_binary(client_secret) and client_secret != "" <-
settings[:client_secret],
redirect_uri when is_binary(redirect_uri) and redirect_uri != "" <-
settings[:redirect_uri],
:ok <- RuntimeConfig.validate_redirect_uri(redirect_uri),
attempt_ttl_seconds when attempt_ttl_seconds in 60..900 <-
settings[:attempt_ttl_seconds] || @default_attempt_ttl_seconds,
request_options when is_list(request_options) <- settings[:request_options] || [] do
{:ok,
%{
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
authorize_url: settings[:authorize_url] || @default_authorize_url,
token_url: settings[:token_url] || @default_token_url,
user_url: settings[:user_url] || @default_user_url,
revoke_url:
settings[:revoke_url] ||
@default_api_url <>
"/applications/" <> URI.encode_www_form(config_value(client_id)) <> "/token",
attempt_ttl_seconds: attempt_ttl_seconds,
request_options: request_options
}}
else
{:error, reason} -> {:error, reason}
_missing -> {:error, :github_oauth_not_configured}
end
end
defp validate_code_and_verifier(code, verifier) do
if code != "" and byte_size(code) <= 1_024 and byte_size(verifier) in 43..128,
do: :ok,
else: {:error, :invalid_oauth_callback}
end
defp validate_scope_configuration do
if Application.get_env(:openagents, :github_oauth_scopes) == @requested_scopes,
do: :ok,
else: {:error, :github_oauth_scope_configuration_invalid}
end
defp oauth_headers do
[
{"accept", "application/json"},
{"user-agent", @user_agent}
]
end
defp oauth_scope do
requested_scopes()
|> Enum.join(" ")
end
defp validate_granted_scopes(granted_scope) do
granted =
granted_scope
|> String.split([",", " "], trim: true)
|> Enum.uniq()
requested = requested_scopes()
if MapSet.new(granted) == MapSet.new(requested),
do: {:ok, requested},
else: {:error, :oauth_scope_mismatch}
end
defp config_value(value), do: to_string(value)
defp api_headers do
[
{"accept", "application/vnd.github+json"},
{"user-agent", @user_agent},
{"x-github-api-version", @github_api_version}
]
end
defp random_url_token do
32
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
defp pkce_challenge(verifier) do
verifier
|> then(&:crypto.hash(:sha256, &1))
|> Base.url_encode64(padding: false)
end
defp secure_equal?(left, right) when byte_size(left) == byte_size(right),
do: Plug.Crypto.secure_compare(left, right)
defp secure_equal?(_left, _right), do: false
end