dev/OAuth2-Backend-Approach.md

9.5 KiB

OAuth2 purpose

A way for the user to tell google to give an access to myapp app


Flow:

When What How
1 Get Code Front ⇢ Google ⇢ Back
2 Exchange Code with Token Back ⇢ Google ⇢ Back ⇢ Front
4 Use Token Front ⇢ Back ⇢ Google ⇢ Back ⇢ Front



Details:

  1. Get Authorization Code

    1. Frontend Navigate to Google URL with a callback url
    2. Google Redirect to Backend's callback url with the authorization code
  2. Exchange Code with Token

    1. Backend POST the code to Google
    2. Google Response to Backend with an access_token and a refresh token
    3. Backend Redirect to Frontend with the access_token in a cookie
  3. Use Token

    1. Frontend GET profile data from Backend using the cookie
    2. Backend GET profile data from Google using the access_token
    3. Google Response to Backend with profile data
    4. Backend Response to Frontend with profile data




1. Get Code

  1. Frontend Navigate to Google https://accounts.google.com/o/oauth2 with a callback url
  2. Google Redirect to Backend callback url https://myapp/api/auth/callback with authorization code

1. Front GET to Google

GET https://accounts.google.com/o/oauth2/v2/auth?
    response_type=code&                             # This indicates you're using the "authorization code" flow.
    client_id=ABC34JHS9D&                           # Your Google API client ID created on the google API console.
    redirect_uri=https://myapp/api/auth/callback&   # The URI Google will redirect to after the user consents.
    scope=email%20profile&                          # The permissions you're requesting (e.g., email, profile).
    state=xyz123                                    # A random string to protect against CSRF attacks.

2. Google 302 to Back

HTTP/1.1 302 Found
Location: https://myapp/api/auth/callback?code=4/0AX4XfWgyVyz-uT8k7WiyLg0Q&state=xyz123
Content-Type: text/html; charset=UTF-8
Content-Length: 0

Security: the state string should be validated upon receiving the response from Google, as it ensures that the response corresponds to the request.




2. Exchange Code with Token

  1. Backend POST the code to Google https://oauth2.googleapis.com/token
  2. Google Response to Backend with an access_token and a refresh token
  3. Backend Redirect to Frontend with the access_token in a cookie

1. Backend POST the code to Google

POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AAAABCX4XfWgyVyziyLg0QHHHHH&
client_id=ABC34JHS9D&
client_secret=PASS1234&
redirect_uri=https://myapp/callback

2. Google Response to Backend

{
  "access_token": "ya29.a0AfH6SMC8Op6zkVX-VoA",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "1//04d5lrjAqU4kS3vGdMvckw",
  "scope": "email profile"
}

3. Backend Redirect to Frontend success page

This redirect will place a cookie in the browser that contains the access token

HTTP/1.1 302 Found
Location: http://localhost:3000/dashboard
Set-Cookie: access_token=ya29.a0AfH6SMC8Op6zkVX-VoA; HttpOnly; Secure; Max-Age=3600; Path=/;



Backend code

Implements the endpoint /auth/google/callback

  1. Recieves authorization code from Google
  2. POST send the authorization code to https://oauth2.googleapis.com/token
  3. POST response the access & refresh tokens
  4. Redirect to Fronend success page with a cookie contains the access token
app.get('/callback', async (req, res) => {
  try {

    //
    // 1. Get the authorization code from Google's redirect
    //
    const { code } = req.query;
    
    //
    // 2. POST the authorization code to Google
    //
    const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        code,
        client_id: process.env.GOOGLE_CLIENT_ID,
        client_secret: process.env.GOOGLE_CLIENT_SECRET,
        redirect_uri: 'https://myapp/callback',
        grant_type: 'authorization_code',
      }),
    });
    
    //
    // 3. Get Response tokens from Google
    //
    const { access_token, refresh_token } = await tokenResponse.json();
    
    //
    // 4. Redirect to Fronend success page with a cookie contains the access token
    //
    res.cookie('access', access_token, {
      httpOnly: true,              // Cannot be accessed by client-side JavaScript
      secure: true,                // Only sent over HTTPS
      sameSite: 'strict',          // CSRF protection
      maxAge: 24 * 60 * 60 * 1000, // 24 hours
    });
    res.redirect('https://myapp/dashboard');


  } catch (error) {
    res.redirect('https://myapp/login');
  }
});

The redirect_uri in the token exchange POST request needs to match EXACTLY the same redirect_uri that was used in the initial authorization request to Google. It's part of OAuth2 security verification.




3. Use Token

1. Frontend GET profile data from Backend

curl -X GET https://myapp/api/auth/profile \
  -H "Cookie: access_token=ya29.a0AfH6SMC8Op6zkVX-VoA" \
  -H "Accept: application/json"

2. Backend GET profile data from Google

curl -X GET "https://www.googleapis.com/oauth2/v3/userinfo" \
  -H "Authorization: Bearer {access_token}" \
  -H "Accept: application/json"

3. Google response to Backend with profile data

{
  "sub": "1234567890",
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "profile": "https://plus.google.com/1234567890",
  "picture": "https://lh3.googleusercontent.com/a-/AOh14GgIXXl5JXzW0c1Szbl-e1Jch1vhl5rHhH65vlK6J5g5PqkGjj1O0p3t8bgVEOykQ6ykFSQ=s96",
  "email": "john.doe@example.com",
  "email_verified": true,
  "locale": "en"
}

4. Backend response to Front with profile data

{
  "sub": "1234567890",
  "name": "John Doe",
  "picture": "https://lh3.googleusercontent.com/8bgVEOykQ6ykFSQ=s96",
  "email": "john.doe@example.com",
}




Frontend Dashboard Code

import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

function DashboardPage() {
  const navigate = useNavigate();
  const [isLoading, setIsLoading] = useState(true);
  const [dashboardData, setDashboardData] = useState(null);

  useEffect(() => {
    async function loadDashboard() {
      try {
        
        // 
        // Single request that both verifies auth and gets data
        //
        const response = await fetch('https://myapp/api/dashboard-data', {
          credentials: 'include'  // Sends the auth cookie
        });

        if (!response.ok) {
          //
          // If auth is invalid, the backend will return 401
          //
          if (response.status === 401) {
            navigate('/login');
            return;
          }
          throw new Error('Failed to load dashboard');
        }

        const data = await response.json();
        setDashboardData(data);
        setIsLoading(false);
      } catch (err) {
        navigate('/login');
      }
    }

    loadDashboard();
  }, [navigate]);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1>Welcome to Dashboard</h1>
    </div>
  );
}




Backend Dashboard

If the frontend token is valid, the backend will response to the request.

If the frontend token is not valid, the backend will:

  1. Get new tokens using refresh token
  2. Set new cookie with new access token
  3. Continue with the original request using new token

or login again

app.get('/api/dashboard-data', async (req, res) => {
  const authToken = req.cookies.auth_token;
  const refreshToken = await getRefreshTokenFromDB(); // Get from your DB

  try {
    //
    // If the token is still valid
    //
    const userData = verifyToken(authToken);
    const dashboardData = await getDashboardData(userData);
    res.json(dashboardData);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      try {
        //
        // 1. Get new tokens using refresh token
        //
        const response = await fetch('https://oauth2.googleapis.com/token', {
          method: 'POST',
          body: JSON.stringify({
            refresh_token: refreshToken,
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
            grant_type: 'refresh_token'
          })
        });

        const { access_token } = await response.json();

        //
        // 2. Set new cookie with new access token
        //
        res.cookie('auth_token', access_token, {
          httpOnly: true,
          secure: true,
          sameSite: 'strict'
        });

        //
        // 3. Continue with the original request using new token
        //
        const userData = verifyToken(access_token);
        const dashboardData = await getDashboardData(userData);
        res.json(dashboardData);
      } catch (refreshError) {
        //
        // If refresh fails, user needs to login again
        //
        res.status(401).json({ error: 'Session expired' });
      }
    } else {
      res.status(401).json({ error: 'Invalid token' });
    }
  }
});