diff --git a/pygitweb/auth_test.py b/pygitweb/auth_test.py
index 387396c..cdf9aa8 100644
--- a/pygitweb/auth_test.py
+++ b/pygitweb/auth_test.py
@@ -1,3 +1,4 @@
+import asyncio
 import json
 import os
 import sys
@@ -6,11 +7,20 @@ from pathlib import Path
 from unittest.mock import AsyncMock, patch
 
 import pytest
+from fastapi import HTTPException
 from fastapi.testclient import TestClient
 
 from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
 from pygitweb.auth_config import AuthConfig, LocalUser, init_auth_config, write_auth_config
-from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, _seal_oauth_state, is_oauth_email_allowed
+from pygitweb.auth_oauth import (
+	OAUTH_STATE_COOKIE_NAME,
+	_exchange_code,
+	_fetch_github_profile,
+	_fetch_google_profile,
+	_fetch_profile,
+	_seal_oauth_state,
+	is_oauth_email_allowed,
+)
 from pygitweb.config import Settings, settings
 from pygitweb.conftest import clear_client_cookies
 from pygitweb.gravatar import gravatar_url
@@ -19,6 +29,19 @@ from pygitweb.password_hash import hash_password, verify_password
 from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
 from pygitweb.sessions import get_session
 
+
+class _DummyResponse:
+	def __init__(self, payload: object, is_success: bool = True) -> None:
+		self._payload = payload
+		self.is_success = is_success
+
+	def raise_for_status(self) -> None:
+		return None
+
+	def json(self) -> object:
+		return self._payload
+
+
 _LOCAL_ADMIN = AuthConfig(
 	auth_mode="local",
 	local_users=[LocalUser(user="admin", password="secret")],
@@ -388,3 +411,151 @@ def test_authenticate_local_user_pass_hash() -> None:
 	assert user is not None
 	assert user.username == "admin"
 	assert authenticate_local_user("admin", "wrong") is None
+
+
+def test_exchange_code_success(oauth_auth_config: AuthConfig) -> None:
+	resp = _DummyResponse({"access_token": "tok", "other": "x"})
+
+	client = AsyncMock()
+	client.post.return_value = resp
+	client.__aenter__.return_value = client
+	client.__aexit__.return_value = None
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
+			out = await _exchange_code(
+				oauth_auth_config,
+				code="auth-code",
+				code_verifier="verifier",
+			)
+		assert out["access_token"] == "tok"
+
+	asyncio.run(runner())
+
+
+def test_exchange_code_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
+	resp = _DummyResponse(["not-a-dict"])
+
+	client = AsyncMock()
+	client.post.return_value = resp
+	client.__aenter__.return_value = client
+	client.__aexit__.return_value = None
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
+			await _exchange_code(
+				oauth_auth_config,
+				code="auth-code",
+				code_verifier="verifier",
+			)
+
+	asyncio.run(runner())
+
+
+def test_fetch_google_profile_success(oauth_auth_config: AuthConfig) -> None:
+	resp = _DummyResponse({"sub": "123", "email": "user@example.com"})
+
+	client = AsyncMock()
+	client.get.return_value = resp
+	client.__aenter__.return_value = client
+	client.__aexit__.return_value = None
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
+			profile = await _fetch_google_profile("access-token")
+		assert profile["email"] == "user@example.com"
+
+	asyncio.run(runner())
+
+
+def test_fetch_google_profile_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
+	resp = _DummyResponse("not-a-dict")
+
+	client = AsyncMock()
+	client.get.return_value = resp
+	client.__aenter__.return_value = client
+	client.__aexit__.return_value = None
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
+			await _fetch_google_profile("access-token")
+
+	asyncio.run(runner())
+
+
+def test_fetch_github_profile_uses_primary_verified_email() -> None:
+	resp_user = _DummyResponse({"login": "octocat", "email": None})
+	resp_emails = _DummyResponse([
+		{"email": "secondary@example.com", "primary": False, "verified": True},
+		{"email": "primary@example.com", "primary": True, "verified": True},
+	])
+
+	client = AsyncMock()
+	client.get.side_effect = (resp_user, resp_emails)
+	client.__aenter__.return_value = client
+	client.__aexit__.return_value = None
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
+			profile = await _fetch_github_profile("access-token")
+		assert profile["email"] == "primary@example.com"
+
+	asyncio.run(runner())
+
+
+def test_fetch_github_profile_rejects_non_mapping() -> None:
+	resp_user = _DummyResponse("not-a-dict")
+
+	client = AsyncMock()
+	client.get.return_value = resp_user
+	client.__aenter__.return_value = client
+	client.__aexit__.return_value = None
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
+			await _fetch_github_profile("access-token")
+
+	asyncio.run(runner())
+
+
+def test_fetch_profile_google(oauth_auth_config: AuthConfig) -> None:
+	token_payload = {"access_token": "tok"}
+	profile: dict[str, object] = {"sub": "123"}
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth._fetch_google_profile", new_callable=AsyncMock, return_value=profile):
+			out = await _fetch_profile(oauth_auth_config, token_payload)
+		assert out is profile
+
+	asyncio.run(runner())
+
+
+def test_fetch_profile_github() -> None:
+	cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "github"})
+	token_payload = {"access_token": "tok"}
+	profile: dict[str, object] = {"id": "123"}
+
+	async def runner() -> None:
+		with patch("pygitweb.auth_oauth._fetch_github_profile", new_callable=AsyncMock, return_value=profile):
+			out = await _fetch_profile(cfg, token_payload)
+		assert out is profile
+
+	asyncio.run(runner())
+
+
+def test_fetch_profile_missing_access_token(oauth_auth_config: AuthConfig) -> None:
+	async def runner() -> None:
+		with pytest.raises(HTTPException):
+			await _fetch_profile(oauth_auth_config, {})
+
+	asyncio.run(runner())
+
+
+def test_fetch_profile_unknown_provider() -> None:
+	cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "other"})
+
+	async def runner() -> None:
+		with pytest.raises(HTTPException):
+			await _fetch_profile(cfg, {"access_token": "tok"})
+
+	asyncio.run(runner())
