defmodule OpenAgents.GitHubOAuthTest do
use OpenAgents.DataCase, async: false
alias OpenAgents.{GitHubOAuth, Repo}
alias OpenAgents.Accounts.OAuthAttempt
setup {Req.Test, :verify_on_exit!}
setup do
original_scopes = Application.fetch_env!(:openagents, :github_oauth_scopes)
Application.put_env(:openagents, :github_oauth_scopes, ["user:email"])
on_exit(fn ->
Application.put_env(:openagents, :github_oauth_scopes, original_scopes)
end)
:ok
end
test "authorization attempts use state, S256 PKCE, bounded scope, and one-time receipts" do
assert {:ok, attempt, authorization_url} = GitHubOAuth.begin_authorization()
query = authorization_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
assert query["client_id"] == "test-github-client-id"
assert query["redirect_uri"] == "http://127.0.0.1:4002/auth/github/callback"
assert query["scope"] == "user:email"
assert query["state"] == attempt["state"]
assert query["code_challenge_method"] == "S256"
assert byte_size(query["code_challenge"]) == 43
refute query["code_challenge"] == attempt["verifier"]
assert :ok = GitHubOAuth.consume_attempt(attempt, attempt["state"])
assert {:error, :invalid_oauth_state} =
GitHubOAuth.consume_attempt(attempt, attempt["state"])
end
test "missing, mismatched, and expired state fail before code exchange" do
assert {:ok, attempt, _url} = GitHubOAuth.begin_authorization()
assert {:error, :invalid_oauth_state} = GitHubOAuth.consume_attempt(nil, "state")
assert {:error, :invalid_oauth_state} = GitHubOAuth.consume_attempt(attempt, "wrong")
from_attempt = Repo.get!(OAuthAttempt, attempt["id"])
from_attempt
|> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
|> Repo.update!()
assert {:error, :invalid_oauth_state} =
GitHubOAuth.consume_attempt(attempt, attempt["state"])
end
test "code exchange and profile lookup send required headers and return bounded identity" do
setup_req_test()
Req.Test.expect(__MODULE__, fn conn ->
assert conn.request_path == "/login/oauth/access_token"
assert ["application/json"] = Plug.Conn.get_req_header(conn, "accept")
assert ["OpenAgents"] = Plug.Conn.get_req_header(conn, "user-agent")
body = Req.Test.raw_body(conn)
assert body =~ "client_id=test-github-client-id"
assert body =~ "client_secret=test-github-client-secret"
assert body =~ "code=github-code"
assert body =~ "code_verifier="
Req.Test.json(conn, %{
"access_token" => "short-lived-token",
"token_type" => "bearer",
"scope" => "user:email"
})
end)
Req.Test.expect(__MODULE__, fn conn ->
assert conn.request_path == "/user"
assert ["Bearer short-lived-token"] = Plug.Conn.get_req_header(conn, "authorization")
assert ["application/vnd.github+json"] = Plug.Conn.get_req_header(conn, "accept")
assert ["2022-11-28"] = Plug.Conn.get_req_header(conn, "x-github-api-version")
Req.Test.json(conn, %{
"id" => 7_654,
"login" => "octo-user",
"avatar_url" => "https://avatars.githubusercontent.com/u/7654?v=4"
})
end)
verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
assert {:ok, profile, access_token, ["user:email"]} =
GitHubOAuth.exchange_and_fetch("github-code", verifier)
assert access_token == "short-lived-token"
assert profile.github_id == 7_654
assert profile.github_login == "octo-user"
assert profile.github_avatar_url == "https://avatars.githubusercontent.com/u/7654?v=4"
assert profile.github_name == nil
end
test "the optional profile name is captured, trimmed, and degrades to none" do
for {provided, expected} <- [
{"Ada Lovelace", "Ada Lovelace"},
{" Ada Lovelace ", "Ada Lovelace"},
{" ", nil},
{nil, nil},
{:absent, nil}
] do
setup_req_test()
Req.Test.expect(__MODULE__, fn conn ->
Req.Test.json(conn, %{
"access_token" => "short-lived-token",
"scope" => "user:email"
})
end)
Req.Test.expect(__MODULE__, fn conn ->
profile = %{
"id" => 7_654,
"login" => "octo-user",
"avatar_url" => "https://avatars.githubusercontent.com/u/7654?v=4"
}
body = if provided == :absent, do: profile, else: Map.put(profile, "name", provided)
Req.Test.json(conn, body)
end)
verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
# GitHub leaves `name` null far more often than not, so an unusable value
# must degrade to no name rather than fail the login.
assert {:ok, profile, _access_token, _scopes} =
GitHubOAuth.exchange_and_fetch("github-code", verifier)
assert profile.github_name == expected
assert profile.github_login == "octo-user"
end
end
test "malformed provider profiles and provider failures are reduced to bounded errors" do
setup_req_test()
Req.Test.expect(__MODULE__, fn conn ->
Req.Test.json(conn, %{
"access_token" => "provider-token",
"scope" => "user:email"
})
end)
Req.Test.expect(__MODULE__, fn conn ->
Req.Test.json(conn, %{
"id" => 88,
"login" => "valid-login",
"avatar_url" => "http://attacker.example/avatar.png"
})
end)
verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
assert {:error, :invalid_github_profile} = GitHubOAuth.exchange_and_fetch("code", verifier)
end
test "a missing or broadened granted scope fails before profile lookup" do
for scope <- [nil, "", "repo", "read:org", "read:user", "user:email,repo"] do
setup_req_test()
Req.Test.expect(__MODULE__, fn conn ->
body = %{"access_token" => "provider-token"}
Req.Test.json(conn, if(scope, do: Map.put(body, "scope", scope), else: body))
end)
verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
expected = if scope, do: :oauth_scope_mismatch, else: :invalid_oauth_token_response
assert {:error, ^expected} = GitHubOAuth.exchange_and_fetch("code", verifier)
end
end
test "sign-in scope stays separate from repository authorization scopes" do
assert GitHubOAuth.required_scopes() == ["repo", "read:org"]
assert GitHubOAuth.requested_scopes() == ["user:email"]
end
defp setup_req_test do
original = Application.fetch_env!(:openagents, :github_oauth)
Application.put_env(
:openagents,
:github_oauth,
Keyword.put(original, :request_options, plug: {Req.Test, __MODULE__})
)
on_exit(fn -> Application.put_env(:openagents, :github_oauth, original) end)
:ok
end
end