Google OIDC with Flask-React

jvaughn619

Jessica Vaughn

Posted on July 5, 2023

Google OIDC with Flask-React

When determining how to authenticate users for an application, keeping a user's data secure is the most important consideration. While there are libraries for hashing and salting passwords to avoid storing them in a local database (which should NEVER be done!), utilizing OIDC to log users into a third-party application is a safe way to handle user authentication. Additionally, users may prefer signing in through credentials they already have, such as Google or a social login, and do not have to remember yet another set of login credentials.

What is OIDC?

OIDC stands for OpenID Connect - it allows for third-party applications to authenticate and verify the identity of users and allows the third-party application to access profile information for the user. It is an additional security layer built onto the OAuth 2 framework. OIDC allows the applications to request information on behalf of the user after verifying the user already has an account with the provider. OIDC is compatible with various social networks - we will look at implementation with Google in this blog.

Overview of OIDC Process

Excerpted from Real Python

Step 1: Third-party application (client) registers for client credentials from a provider (like Google); client specifies what information they would like to have access to from users

Step 2: The client sends a request to the provider’s authorization URL

Step 3: The provider asks the user to authenticate their identify by logging in with their provider credentials

Step 4: If the user is authenticated, the provider asks the user to consent to the client acting on their behalf and accessing information specified in step 1

Step 5: If the user provides consent, the provider sends the client a unique authorization code

Step 6: The client sends the authorization code back to the provider’s token URL

Step 7: The provider sends the client tokens to use with other provider URLs on behalf of the user

Implementation

This blog assumes a Flask-React application is already created along with a database - I typically organize my project tree with a 'client' folder housing the React application and a 'server' folder housing the Flask application. Feel free to clone and fork my repo for Craftsy, an e-commerce application that utilizes Google login with OIDC.

Create a requirements.txt file at the root of your project directory and add the following requirements:
requirements.txt

requests==2.21.0
Flask==1.0.2
oauthlib==3.0.1
pyOpenSSL==19.0.0
Flask-Login==0.4.1
Enter fullscreen mode Exit fullscreen mode

To install these requirements, enter the pipenv shell and run pip install -r requirements.txt

You'll install additional packages throughout the project.

Create a .env file at the root of your project directory. Remember to add this file to your gitignore to avoid pushing up secrets to your GitHub repository. OIDC with Google login requires your Flask application be configured with a SECRET_KEY, GOOGLE_CLIENT_ID, and GOOGLE_CLIENT_SECRET, which should all be stored as variables in your .env file to avoid sharing secrets. Register your third-party application on the Google Cloud Console, then add your Client ID and Client Secret from the Cloud Console to your .env file. The Secret Key can be any random key you would like.

Additionally, I have my React application configured to run on port 4000 and my Flask application configured to port 5555. You'll see these ports referenced throughout this blog, make sure to change them to your own ports if needed.

Flask Configuration

server folder
I organize my server-side code within the server folder into four Python files: app.py config.py models.py and seed.py
As you add imports, make sure to also install them from your pipenv shell using pip install.

config.py
Top level imports:

import os
from dotenv import load_dotenv
from flask import Flask
Enter fullscreen mode Exit fullscreen mode

Instantiate your Flask app in this file. The required code for utilizing Google and OIDC is the following:

load_dotenv()
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY')
app.config['GOOGLE_CLIENT_ID'] = os.environ.get("GOOGLE_CLIENT_ID", None)
app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get("GOOGLE_CLIENT_SECRET", None)
app.config['GOOGLE_DISCOVERY_URL'] = (
    "https://accounts.google.com/.well-known/openid-configuration"
)
Enter fullscreen mode Exit fullscreen mode

dotenv and os are Python modules that allow you to access the variables from your .env file. Remember to instantiate with load_dotenv() at the top of your config file or your .env variables will not be loaded into your application correctly.

app.py
Top level imports:

import os
from flask import (
    redirect, 
    request, 
    session, 
    make_response, 
    jsonify,
)
from flask_login import (
    LoginManager,
    current_user,
    login_user,
    logout_user,
)
from flask_restful import Resource
from oauthlib.oauth2 import WebApplicationClient
from config import app
from models import User
Enter fullscreen mode Exit fullscreen mode
login_manager = LoginManager()
login_manager.init_app(app)

GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None)
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", None)
GOOGLE_DISCOVERY_URL = (
    "https://accounts.google.com/.well-known/openid-configuration"
)
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'

client = WebApplicationClient(GOOGLE_CLIENT_ID)
Enter fullscreen mode Exit fullscreen mode

In the code block above, we are instantiating the LoginManager from flask_login, pulling in our Google environment variables from our .env, and instantiating our WebApplicationClient with our Client ID from the Google Cloud Console.

models.py
Top level import required:

from flask_login import UserMixin
Enter fullscreen mode Exit fullscreen mode

Create a User model with the minimum following columns and method:

class User(db.Model, UserMixin):
__tablename__ = 'users'

id = db.Column(db.String, primary_key=True, unique=True)
name = db.Column(db.String)
email = db.Column(db.String)
profile_pic = db.Column(db.String)

def get(user_id):
    user = User.query.filter_by(id=user_id).first()
    return user
Enter fullscreen mode Exit fullscreen mode

The UserMixin import adds the methods necessary for OIDC login without having to write them out yourself. Check out the documentation here.

The four columns specified in the User model connect to the information we'll request from Google and receive back. It is essential that the id is set to a String data type because the Google id being sent to us is a String.

Run a database migration to add your User model. Let's return to our app.py file.

app.py

@login_manager.user_loader
def load_user(user_id):
    return User.get(user_id)

def get_google_provider_cfg():
    return requests.get(GOOGLE_DISCOVERY_URL).json()
Enter fullscreen mode Exit fullscreen mode

We use the @login_manager to load our user from the User.get() method which wrote in our models.py file. The get_google_provider_cgf() method is required to configure our Flask app.

We need to write four endpoint routes into our app.py file to handle all of the steps of OIDC authentication: Home ('/'), Login ('/login'), Login Callback ('/login/callback'), and Logout ('/logout'). I mixed in Flask Restful conventions for my Home (CheckSession) and Logout routes as I later added local sign up and login. I have extracted this code to include only the parts that are required for Google login with OIDC.

app.py

class CheckSession(Resource):
    def get(self):
        if current_user.is_authenticated:
            return current_user.to_dict(), 200
        return {'error': '401 Unauthorized'}, 401

@app.route("/login")
def login():
    google_provider_cfg = get_google_provider_cfg()
    authorization_endpoint = google_provider_cfg["authorization_endpoint"]

    request_uri = client.prepare_request_uri(
        authorization_endpoint,
        redirect_uri=request.base_url + "/callback",
        scope=["openid", "email", "profile"],
    )
    return redirect(request_uri)

@app.route("/login/callback")
def callback():
    code = request.args.get("code")

    google_provider_cfg = get_google_provider_cfg()
    token_endpoint = google_provider_cfg["token_endpoint"]

    token_url, headers, body = client.prepare_token_request(
        token_endpoint,
        authorization_response=request.url,
        redirect_url=request.base_url,
        code=code,
    )
    token_response = requests.post(
        token_url,
        headers=headers,
        data=body,
        auth=(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET),
    )

client.parse_request_body_response(json.dumps(token_response.json()))

    userinfo_endpoint = google_provider_cfg["userinfo_endpoint"]
    uri, headers, body = client.add_token(userinfo_endpoint)
    userinfo_response = requests.get(uri, headers=headers, data=body)

    if userinfo_response.json().get("email_verified"):
        unique_id = userinfo_response.json()["sub"]
        users_email = userinfo_response.json()["email"]
        picture = userinfo_response.json()["picture"]
        users_name = userinfo_response.json()["given_name"]
    else:
        return "User email not available or not verified by Google.", 400

    user = User(
        id=unique_id, name=users_name, email=users_email, profile_pic=picture
    )
    if not User.get(unique_id):
        user = User(
            id=unique_id, 
            name=users_name, 
            email=users_email, 
            profile_pic=picture
        )
        db.session.add(user)
        db.session.commit()
    login_user(user)
    return redirect('http://localhost:4000/')

class Logout(Resource):
    def delete(self):
        if current_user:
            logout_user()
            return {}, 204
        return {"error": "401 Unauthorized"}, 401

api.add_resource(CheckSession, '/check_session', endpoint='check_session')
api.add_resource(Logout, '/logout', endpoint='logout')
Enter fullscreen mode Exit fullscreen mode

Remember we imported current_user, login_user, and logout_user from the Flask-Login package, which saves us from writing each of these methods ourselves. The UserMixin added to the User model adds a number of properties and methods to our User model, including is_authenticated, which we use in the CheckSession route. Once we've gotten these four endpoints written, we can move to configuring the React front end of this process.

React Configuration

client folder

Great news - configuring the React side of the application for Google login with OIDC is pretty simple. All of the authentication happens on the server-side in our Flask routes, so from the front end we really only need a few things.

Ensure you've set up a ('/') home route on your front end. I configured my home route using React Router v6, but it is important that there is a home route configured for Google login with OIDC to redirect to.

Let's create a Button component that will have our 'Login with Google' functionality.

Button.js

export default function Button({ children, onClick }) {
    return (
        <button onClick={onClick}>{children}</button>
    )
}
Enter fullscreen mode Exit fullscreen mode

Next navigate to the component you'd like to use the Login with Google button in, and import and render the Button.
Component.js

import Button from './Button'
import google from '../images/google.png'

export default function Component() {
    function handleClick() {
        window.open("http://localhost:5555/login", "_self")
}
return (
    <div>
    <Button 
    onClick={handleClick} 
    children={
        <div className="flex gap-2 items-center justify-center">
        <img src={google} alt="google-logo" className="h-7 w-7"/>
        <span>Login with Google</span>
        </div>}
        />
    </div>
)}
Enter fullscreen mode Exit fullscreen mode

In this component, I imported a Google logo image that I've added to my file and create my own Login button to mirror the Login with Google buttons I have seen using Tailwind CSS. The button CSS can be configured any way you'd like, the important part for utilizing Google login with OIDC is the handleClick() function.

In the handleClick() function, we are navigating to the backend ('/login') route we wrote in our app.py file, which starts the Google login OIDC authentication process. You may have to configure CORS to allow the application to open another window. The "_self" parameter opens the window in a new tab. Back in our app.py ('/login/callback') route, our last line of code was:

app.py ('/login/callback') route

    return redirect('http://localhost:4000/')
Enter fullscreen mode Exit fullscreen mode

Once we direct users to the ('/login) route from our React application with the handleClick() function, the ('/login') route authenticates the user, moves to the ('/login/callback') route, then if all goes well, redirects the user back to our front end ('/) home route.

Testing

When testing each step of this configuration, make sure to follow the error messages in the console. I also found it necessary to clear my browser cookies between changes to my code especially when coming across internal server errors. All of this code is configured to work in a local development environment.

Conclusion and Resources

This article from Real Python was critical to my successful implementation of OIDC and Google in my Flask-React application. Best of luck with implementing Google login through OIDC!

💖 💪 🙅 🚩
jvaughn619
Jessica Vaughn

Posted on July 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Google OIDC with Flask-React
google Google OIDC with Flask-React

July 5, 2023