Testing OAuth2 Provider Integrations in CI: GitHub, Google, and Auth0
OAuth2 integrations are painful to test in CI because the authorization code flow requires a browser and human interaction. This guide covers how to test OAuth2 without browser automation: mocking the token exchange, testing the callback handler, validating token storage, testing refresh flows, and running full browser-based auth tests with HelpMeTest's browser state persistence.
OAuth2 integrations fail in ways that are embarrassing in production: the authorization URL is wrong so users get a 400 from GitHub, the state parameter validation is missing so you're vulnerable to CSRF, the refresh token logic doesn't handle expiry, or the scope didn't include what you needed.
Testing OAuth2 properly in CI requires a strategy that doesn't depend on real OAuth provider interaction.
The OAuth2 Flow
The standard OAuth2 Authorization Code flow has these steps:
- Your app generates a state parameter and redirects user to the provider's authorization URL
- User logs in and authorizes your app
- Provider redirects back to your callback URL with
codeandstate - Your app exchanges the code for tokens at the provider's token endpoint
- Your app stores the access token and (if provided) refresh token
- Your app uses the access token to call protected APIs
- When the token expires, your app uses the refresh token to get a new one
Steps 1, 3-7 are entirely testable without browser interaction. Only step 2 requires a human.
Testing the Authorization URL Builder
from urllib.parse import urlparse, parse_qs
from app.oauth.github import build_github_auth_url
def test_github_auth_url_includes_required_params():
"""Authorization URL must include all required OAuth2 parameters."""
state = "random_csrf_token_abc123"
url = build_github_auth_url(state=state)
parsed = urlparse(url)
params = parse_qs(parsed.query)
assert parsed.scheme == "https"
assert parsed.netloc == "github.com"
assert parsed.path == "/login/oauth/authorize"
assert "client_id" in params
assert "redirect_uri" in params
assert "scope" in params
assert "state" in params
assert params["state"][0] == state
def test_github_auth_url_requests_correct_scopes():
"""Authorization URL must request required scopes."""
url = build_github_auth_url(state="test_state")
params = parse_qs(urlparse(url).query)
scopes = params["scope"][0].split()
assert "read:user" in scopes or "user" in scopes, \
"Must request user profile scope"
assert "user:email" in scopes, \
"Must request email scope for user identification"
def test_google_auth_url_includes_pkce():
"""Google OAuth with PKCE must include code_challenge."""
from app.oauth.google import build_google_auth_url, generate_pkce_pair
verifier, challenge = generate_pkce_pair()
url = build_google_auth_url(state="test_state", code_challenge=challenge)
params = parse_qs(urlparse(url).query)
assert "code_challenge" in params
assert params["code_challenge"][0] == challenge
assert "code_challenge_method" in params
assert params["code_challenge_method"][0] == "S256"Testing the Callback Handler
The callback handler is the most security-critical part. Test it thoroughly:
from fastapi.testclient import TestClient
from app.main import app
import secrets
client = TestClient(app)
def test_callback_with_valid_code_and_state():
"""Valid callback must exchange code and redirect to dashboard."""
# Set up session with stored state
with client.session_transaction() as session:
session["oauth_state"] = "valid_state_12345"
# Mock the token exchange
mock_tokens = {
"access_token": "gho_test_access_token_12345",
"token_type": "bearer",
"scope": "read:user user:email"
}
mock_user = {"id": 12345, "login": "alice", "email": "alice@example.com"}
with patch("app.oauth.github.exchange_code_for_token", return_value=mock_tokens), \
patch("app.oauth.github.get_user_info", return_value=mock_user):
response = client.get(
"/auth/github/callback",
params={"code": "test_auth_code", "state": "valid_state_12345"},
follow_redirects=False
)
assert response.status_code == 302
assert "/dashboard" in response.headers["location"]
# Verify session is authenticated
with client.session_transaction() as session:
assert session.get("user_id") == 12345
assert "oauth_state" not in session, "State must be cleared after use"
def test_callback_rejects_mismatched_state():
"""CSRF protection: mismatched state must be rejected."""
with client.session_transaction() as session:
session["oauth_state"] = "expected_state"
response = client.get(
"/auth/github/callback",
params={"code": "test_code", "state": "attacker_state"},
follow_redirects=False
)
assert response.status_code in (400, 403), \
"Mismatched state must return 400 or 403, not redirect"
with client.session_transaction() as session:
assert "user_id" not in session, "Must not authenticate on CSRF attack"
def test_callback_rejects_missing_state():
"""Missing state parameter must be rejected."""
response = client.get(
"/auth/github/callback",
params={"code": "test_code"}, # No state
follow_redirects=False
)
assert response.status_code in (400, 403)
def test_callback_handles_provider_error():
"""OAuth error response from provider must be handled gracefully."""
response = client.get(
"/auth/github/callback",
params={"error": "access_denied", "error_description": "User denied access"},
follow_redirects=False
)
# Should redirect to login with error message, not crash
assert response.status_code == 302
location = response.headers["location"]
assert "/login" in location or "/auth" in location
with client.session_transaction() as session:
assert "user_id" not in session
def test_callback_rejects_replayed_code():
"""Authorization code must only be used once."""
with client.session_transaction() as session:
session["oauth_state"] = "valid_state"
call_count = {"count": 0}
def mock_exchange(code):
call_count["count"] += 1
if call_count["count"] > 1:
raise Exception("Code has already been used")
return {"access_token": "token_12345", "token_type": "bearer"}
with patch("app.oauth.github.exchange_code_for_token", side_effect=mock_exchange), \
patch("app.oauth.github.get_user_info", return_value={"id": 1, "email": "a@b.com"}):
# First use — should succeed
response1 = client.get(
"/auth/github/callback",
params={"code": "auth_code_123", "state": "valid_state"}
)
# Second use with same code — should fail
with client.session_transaction() as session:
session["oauth_state"] = "valid_state"
with patch("app.oauth.github.exchange_code_for_token", side_effect=mock_exchange):
response2 = client.get(
"/auth/github/callback",
params={"code": "auth_code_123", "state": "valid_state"},
follow_redirects=False
)
assert response2.status_code in (400, 302)Testing Token Storage and Retrieval
from app.oauth.storage import TokenStore
def test_token_storage_encrypts_sensitive_data():
"""Access tokens must not be stored in plaintext."""
store = TokenStore()
store.save(user_id=123, provider="github", tokens={
"access_token": "gho_plaintext_token",
"refresh_token": "ghr_plaintext_refresh"
})
# Read raw storage value
raw_value = store.get_raw(user_id=123, provider="github")
assert "gho_plaintext_token" not in raw_value, \
"Access token must be encrypted at rest"
assert "ghr_plaintext_refresh" not in raw_value, \
"Refresh token must be encrypted at rest"
def test_token_retrieval_decrypts_correctly():
"""Stored tokens must be decryptable."""
store = TokenStore()
original_tokens = {
"access_token": "gho_test_access",
"refresh_token": "ghr_test_refresh",
"expires_at": int(time.time()) + 3600
}
store.save(user_id=456, provider="google", tokens=original_tokens)
retrieved = store.get(user_id=456, provider="google")
assert retrieved["access_token"] == original_tokens["access_token"]
assert retrieved["refresh_token"] == original_tokens["refresh_token"]Testing Refresh Token Flow
def test_app_refreshes_expired_token():
"""App must automatically refresh expired access tokens."""
store = TokenStore()
store.save(user_id=789, provider="github", tokens={
"access_token": "expired_token",
"refresh_token": "valid_refresh_token",
"expires_at": int(time.time()) - 60 # Expired 1 minute ago
})
new_tokens = {
"access_token": "new_access_token_fresh",
"refresh_token": "new_refresh_token",
"expires_at": int(time.time()) + 3600
}
with patch("app.oauth.github.refresh_access_token", return_value=new_tokens) as mock_refresh:
token = get_valid_token(user_id=789, provider="github")
mock_refresh.assert_called_once_with("valid_refresh_token")
assert token == "new_access_token_fresh"
# New tokens must be stored
stored = store.get(user_id=789, provider="github")
assert stored["access_token"] == "new_access_token_fresh"
def test_app_handles_refresh_token_expiry():
"""When refresh token is also expired, user must be prompted to re-authenticate."""
store = TokenStore()
store.save(user_id=789, provider="github", tokens={
"access_token": "expired_token",
"refresh_token": "also_expired_refresh",
"expires_at": int(time.time()) - 3600
})
with patch("app.oauth.github.refresh_access_token",
side_effect=Exception("invalid_grant: refresh token expired")):
from app.oauth.exceptions import TokenExpiredError
with pytest.raises(TokenExpiredError):
get_valid_token(user_id=789, provider="github")
def test_refresh_uses_correct_client_credentials():
"""Token refresh must include client_id and client_secret."""
with patch("httpx.post") as mock_post:
mock_post.return_value.json.return_value = {
"access_token": "new_token",
"expires_in": 3600
}
mock_post.return_value.status_code = 200
from app.oauth.github import refresh_access_token
refresh_access_token("test_refresh_token")
call_kwargs = mock_post.call_args.kwargs
assert call_kwargs["data"]["client_id"] == os.environ["GITHUB_CLIENT_ID"]
assert call_kwargs["data"]["client_secret"] == os.environ["GITHUB_CLIENT_SECRET"]
assert call_kwargs["data"]["refresh_token"] == "test_refresh_token"
assert call_kwargs["data"]["grant_type"] == "refresh_token"Testing Auth0 Integration
Auth0 has a management API for testing. For CI, mock it:
def test_auth0_user_creation_on_first_login():
"""First OAuth login must create a user in our database."""
auth0_user = {
"sub": "github|12345",
"name": "Alice Johnson",
"email": "alice@example.com",
"picture": "https://avatars.githubusercontent.com/alice",
"email_verified": True
}
with patch("app.oauth.auth0.verify_token", return_value=auth0_user):
response = client.post(
"/auth/callback",
json={"id_token": "eyJhbGci..."}
)
assert response.status_code == 200
from app.database import get_user_by_email
user = get_user_by_email("alice@example.com")
assert user is not None
assert user.name == "Alice Johnson"
assert user.auth_provider == "github"
assert user.provider_id == "12345"
def test_auth0_existing_user_updates_profile():
"""Repeated login must update profile data, not create duplicate."""
# Pre-create the user
create_test_user(email="alice@example.com", name="Old Name")
auth0_user = {
"sub": "github|12345",
"name": "Alice Johnson Updated",
"email": "alice@example.com",
"email_verified": True
}
with patch("app.oauth.auth0.verify_token", return_value=auth0_user):
client.post("/auth/callback", json={"id_token": "eyJhbGci..."})
from app.database import get_users_by_email
users = get_users_by_email("alice@example.com")
assert len(users) == 1, "Must not create duplicate user on repeated login"
assert users[0].name == "Alice Johnson Updated"Browser-Based OAuth2 Testing with HelpMeTest
For full end-to-end testing of the OAuth2 flow including the browser redirect, HelpMeTest's browser state persistence is perfect. Authenticate once, save the state, and reuse it across tests:
# Install HelpMeTest
curl -fsSL https://helpmetest.com/install <span class="hljs-pipe">| bash
helpmetest loginThen write a test that performs the full OAuth2 flow once and saves the authenticated state:
*** Test Cases ***
GitHub OAuth Login — Save State
Go To https://yourapp.com/login timeout=10s
Click button[data-provider="github"]
# User will be redirected to GitHub
Wait For URL to contain github.com timeout=10s
Fill Text input[name="login"] testuser@example.com
Fill Text input[name="password"] test_password_123
Click input[type="submit"]
Wait For URL to contain yourapp.com/dashboard timeout=15s
Save As GitHubUser
*** Test Cases ***
Authenticated Feature Works
As GitHubUser
Go To https://yourapp.com/profile
Wait For Elements State .profile-data visible timeout=10s
Get Text .user-email == testuser@example.comThe Save As GitHubUser stores the complete browser state (cookies, localStorage) so every subsequent test that uses As GitHubUser starts already authenticated — no repeated OAuth redirect flows.
CI/CD Integration
# .github/workflows/oauth-tests.yml
name: OAuth2 Integration Tests
on:
push:
paths:
- 'app/oauth/**'
- 'app/auth/**'
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install fastapi httpx pytest authlib
- run: pytest tests/unit/oauth/ -v
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- run: pip install fastapi httpx pytest authlib
- run: pytest tests/integration/oauth/ -v
env:
GITHUB_CLIENT_ID: ${{ secrets.GITHUB_TEST_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_TEST_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_TEST_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_TEST_CLIENT_SECRET }}
TOKEN_ENCRYPTION_KEY: ${{ secrets.TEST_TOKEN_ENCRYPTION_KEY }}Conclusion
Testing OAuth2 integrations without browser automation is entirely possible: unit test the authorization URL builder, test the callback handler with mock token exchanges, verify CSRF protection (state parameter), test token storage encryption, and test refresh token flows including expiry. The browser-based portion (user redirect and login) can be tested with HelpMeTest's browser state persistence — authenticate once, save the state, and reuse it across hundreds of tests without repeated OAuth flows.