Testing OAuth2 Provider Integrations in CI: GitHub, Google, and Auth0

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:

  1. Your app generates a state parameter and redirects user to the provider's authorization URL
  2. User logs in and authorizes your app
  3. Provider redirects back to your callback URL with code and state
  4. Your app exchanges the code for tokens at the provider's token endpoint
  5. Your app stores the access token and (if provided) refresh token
  6. Your app uses the access token to call protected APIs
  7. 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 login

Then 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.com

The 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.

Read more