Skip to content

GitHub App Authentication

This documentation describes the GitHub App authentication utility used for secure user login across multiple pages in the application. The implementation follows GitHub's web application flow with PKCE (Proof Key for Code Exchange) security hardening.

Overview

The GitHub Auth system provides a reusable, secure way to authenticate users with their GitHub accounts using GitHub App credentials. It is currently integrated with the Policy Reports page and can be easily adopted by other pages in the application.

Reference: GitHub Official Documentation on User Access Tokens for Apps

Architecture

The authentication system consists of three components working together:

  1. Frontend Utility (frontend/src/utilities/githubAuth.js) - Orchestrates the authentication flow
  2. Backend Utility (backend/src/utilities/githubAuth.js) - Builds URLs and calls GitHub APIs
  3. Backend Routes (backend/src/routes/githubAuth.js) - Exposes shared authentication endpoints

Authentication Flow

The system implements the GitHub web application flow with PKCE:

User                Frontend                Backend                GitHub
  |                     |                       |                    |
  |--Login Click------->|                       |                    |
  |                     |--Generate State------>|                    |
  |                     |--Generate PKCE------->|                    |
  |                     |                       |--Redirect--------->|
  |                     |                       |    to /authorize   |
  |<------GitHub Callback----(code, state)------|<------------------|
  |                     |--Validate State------>|                    |
  |                     |--Exchange Code+PKCE-->|                    |
  |                     |                       |--Token Request---->|
  |                     |                       |<--Access Token-----|
  |                     |<-Set Cookie-----------|                    |
  |<----Redirect--------|  (httpOnly, 3 hours)  |                    |
  |    (authenticated)  |                       |                    |

Security Features

PKCE (RFC 7636)

Proof Key for Code Exchange protects against authorisation code interception attacks:

  • Code Verifier: A cryptographically random 32-byte string generated on the frontend
  • Code Challenge: SHA-256 hash of the verifier, base64url encoded
  • Challenge Method: Always uses 'S256' (SHA-256)
  • Backend Verification: GitHub validates that the code verifier matches the challenge

State Parameter

Prevents CSRF attacks by:

  • Generating a random state string on the frontend
  • Storing it in sessionStorage
  • Validating the returned state matches the original
  • Clearing after successful validation

Access tokens are stored in httpOnly cookies to prevent XSS attacks:

  • httpOnly: Token cannot be accessed from JavaScript
  • secure: Cookie only sent over HTTPS in production
  • sameSite: 'lax' prevents CSRF via cookie-based requests
  • maxAge: 3 hours (180 minutes)

API Endpoints

All endpoints are mounted at /api/github/auth/ and are shared across the entire application. Any page can use these same endpoints without duplication.

GET /api/github/auth/login

Initiates the GitHub authorisation request.

Query Parameters:

  • state (string, required) - CSRF protection state generated by frontend
  • code_challenge (string, required) - PKCE code challenge (base64url SHA-256)
  • code_challenge_method (string, required) - Always 'S256'

Response: Redirects to https://github.com/login/oauth/authorize with encoded parameters

POST /api/github/auth/token

Exchanges the authorisation code for an access token.

Request Body:

{
  "code": "string", // Authorisation code from GitHub
  "codeVerifier": "string" // PKCE code verifier (base64url encoded)
}

Response (Success):

{
  "success": true
}

Sets githubUserToken cookie (httpOnly, 3 hours, secure, sameSite=lax)

Response (Error):

{
  "error": "string" // Error message from GitHub
}

Status: 400 Bad Request

GET /api/github/auth/status

Checks whether a valid authenticated session exists.

Response:

{
  "authenticated": boolean
}

No authentication required - checks for presence of githubUserToken cookie

GET /api/github/auth/user

Retrieves the authenticated user's GitHub profile.

Response (Authenticated):

{
  "login": "string", // GitHub username
  "name": "string", // User's full name
  "avatar_url": "string" // URL to avatar image
}

Response (Unauthenticated):

{
  "error": "Not authenticated"
}

Status: 401 Unauthorized

POST /api/github/auth/logout

Clears the authentication cookie and ends the session.

Response:

{
  "success": true
}

Frontend Integration

Basic Usage

Import the utility functions:

import {
  loginWithGitHub,
  logoutUser,
  checkAuthStatus,
  fetchGitHubUserProfile,
  handleAuthCallback,
  retrievePersistedFormState,
} from '../utilities/githubAuth';

Initialising on Page Load

Run this effect on page mount to handle OAuth redirects and restore auth state:

useEffect(() => {
  const initialiseAuth = async () => {
    // Process OAuth callback if returning from GitHub
    await handleAuthCallback({ redirectPath: '/your-page-path' });

    // Check if user is authenticated
    const authenticated = await checkAuthStatus();

    if (authenticated) {
      // Fetch user profile
      const profile = await fetchGitHubUserProfile();
      // Use profile.login, profile.name, profile.avatar_url
    }

    setIsAuthenticated(authenticated);
  };

  initialiseAuth();
}, []);

Login Button

Call loginWithGitHub() when user clicks the login button:

const handleLogin = async () => {
  await loginWithGitHub({
    redirectPath: '/your-page-path',
    formState: {
      // Optional: persist form state during auth
      fieldName: fieldValue,
      anotherField: anotherValue,
    },
  });
};

<button onClick={handleLogin}>Login with GitHub</button>;

Logout Button

Call logoutUser() to clear authentication:

const handleLogout = async () => {
  const success = await logoutUser();
  if (success) {
    setIsAuthenticated(false);
    // Reset user-specific state
  }
};

<button onClick={handleLogout}>Logout</button>;

Form State Persistence

The utility can save form state before redirecting to GitHub and restore it after authentication completes. This is useful to preserve user selections during the auth flow:

During Login:

Pass formState object to loginWithGitHub() with fields you want to preserve:

await loginWithGitHub({
  redirectPath: '/your-page-path',
  formState: {
    fieldOne: valueOne,
    fieldTwo: valueTwo,
  },
});

During Initialisation:

Retrieve persisted state after auth completes:

const persistedState = retrievePersistedFormState();
if (persistedState.fieldOne) {
  setFieldOne(persistedState.fieldOne);
}

Form state is only saved during an active login flow. It is never persisted between page loads.

Adding GitHub Auth to Another Page

Backend Setup

The backend authentication endpoints are already implemented and shared across all pages at /api/github/auth/. No page-specific backend route setup is required.

Important: The redirectPath for your new page (e.g. /your-page-path) must be registered as a callback URL in the GitHub App settings (GitHub Developer Settings → Your App → Callback URLs). GitHub will reject the OAuth flow for any unregistered redirect URI.

Frontend Setup

The frontend utility automatically uses the shared /api/github/auth/ endpoint path. No per-page configuration needed.

  1. Import authentication utilities in your page component:
import {
  loginWithGitHub,
  logoutUser,
  checkAuthStatus,
  fetchGitHubUserProfile,
  handleAuthCallback,
} from '../utilities/githubAuth';
  1. Add authentication state:
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthLoading, setIsAuthLoading] = useState(true);
const [username, setUsername] = useState(null);
  1. Add initialisation effect (see "Initialising on Page Load" above)

  2. Add login and logout button handlers

  3. Conditionally render authenticated content:

{
  isAuthenticated ? (
    <div>Logged in as {username}</div>
  ) : (
    <button onClick={handleLogin}>Login with GitHub</button>
  );
}

Environment Configuration

Ensure these variables are set in the backend environment (this is application-wide, not per-page):

  • GITHUB_APP_CLIENT_ID - GitHub App Client ID
  • GITHUB_APP_CLIENT_SECRET - GitHub App Client Secret
  • FRONTEND_URL - Frontend base URL (e.g., https://example.com) used for all auth redirects
  • NODE_ENV - Set to 'production' for secure cookies

Token Scope

The authentication requests the following scopes from GitHub:

  • user:email - Access to user email addresses
  • read:org - Read access to organisation data

These scopes enable the utility to fetch user profile information and access organisation data. To modify scopes, update backend/src/utilities/githubAuth.js in the buildGitHubAuthoriseUrl() function.

Troubleshooting

"400 Bad Request" on Token Exchange

Cause: Missing or invalid PKCE parameters

Solution: Ensure loginWithGitHub() is called with the default parameters and that sessionStorage is not being cleared before token exchange

Cause: Insecure connection in production or incorrect secure flag

Solution: Verify NODE_ENV=production, ensure HTTPS is used, and check that secure flag in cookie settings is correct

User Profile Returns Null

Cause: Token is expired (3-hour expiration) or cookie was cleared

Solution: User must re-authenticate. Call logoutUser() and show login button again.

Form State Not Persisting

Cause: Form state passed to loginWithGitHub() was null or empty

Solution: Ensure formState object has at least one truthy value. Only truthy values will be saved.

Implementation Notes

  • The backend auth router is centralised in backend/src/routes/githubAuth.js and mounted once at /api/github/auth.
  • The frontend utility is page-agnostic and can be used on any page that needs GitHub authentication.
  • Token expiration is 3 hours. Consider adding a token refresh mechanism if longer sessions are needed.
  • Copilot already has its own OAuth implementation in backend/src/routes/copilot.js. This can be refactored in future to use the shared backend utility.