Build a Resume Portal API with SSO

Build a FastAPI backend with Google and Microsoft SSO and role-based access control.

Introduction

30 Second Summary

Every time you click "Sign in with Google" on a website, there is a whole authentication system working behind the scenes. That system verifies your identity, decides what you are allowed to access, and keeps your session alive without asking you to log in again on every page.

In this project, you will build a Resume Portal API from scratch. Users log in with their real Google or Microsoft accounts, submit their profile details, and administrators review and approve those submissions.

What You'll Build

A FastAPI backend with Single Sign-On (SSO) authentication, JWT-based sessions, and a complete profile approval workflow backed by a SQLite database.

By the end of this project, you'll have:

  • A running API with Google and Microsoft SSO login that issues JWT tokens for authenticated sessions.
  • A working role-based access control system with user, admin, and super_admin permission levels.
  • A complete profile submission and admin approval workflow with persistent database storage.
  • Secret Mission: Add a "reject with reason" feature and a super_admin-only endpoint to promote users to admin role.

Are there any prerequisites?

You need basic Python knowledge (functions, classes, and type hints). No prior backend, database, or authentication experience is required.

Set Up the Project

Before you can build an API that handles authentication and user profiles, you need a working development environment.

In this step, you will create a project folder, set up a Python virtual environment, install the required packages, and scaffold the file structure for your FastAPI application.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install all project dependencies.
  • Create the project file structure and verify the server starts.
Create project folder and Python virtual environment

A virtual environment keeps your project's packages isolated from the rest of your system. This prevents version conflicts between different Python projects on your machine.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir resume-portal-api
cd resume-portal-api
  • Create a Python virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay separate from your system Python.

This means you can have different versions of the same package across different projects without conflicts.

  • Install all project dependencies by running:
pip install "fastapi[standard]" sqlmodel fastapi-sso PyJWT python-dotenv

What are these packages?

  • fastapi[standard] is the web framework for building your API, bundled with a development server.
  • sqlmodel handles database models using Python type hints.
  • fastapi-sso provides Google and Microsoft SSO login helpers.
  • PyJWT creates and verifies JSON Web Tokens for session management.
  • python-dotenv loads environment variables from a .env file.
  • Confirm the packages installed correctly by running:
pip list | grep -i fastapi

You should see fastapi and fastapi-sso listed in the output with their version numbers.

Seeing an error during install?

  • Make sure you see (venv) in your terminal prompt. If not, re-run the activate command above.
  • If you see a permissions error, you may be running outside the virtual environment. Never use sudo pip install inside a venv.

Help me troubleshoot my pip install error in this FastAPI project.

Create project file structure

Your API will be organized into separate files for configuration, models, authentication, and route handlers. This modular structure keeps each concern in its own place.

  • Create the routers directory and all project files by running:
mkdir routers
touch main.py config.py models.py auth.py database.py .env
touch routers/__init__.py routers/sso.py routers/profiles.py routers/admin.py

Why a routers directory?

FastAPI uses routers to group related endpoints. Keeping SSO, profile, and admin endpoints in separate files makes it easy to work on one feature without touching others.

The __init__.py file tells Python to treat the routers folder as a package so you can import from it.

  • Confirm the files were created by running:
ls

You should see main.py, config.py, models.py, auth.py, database.py, .env, and the routers folder in the output.

Now open the project in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the resume-portal-api folder, then click Open.
  • Open .env from the VS Code Explorer sidebar and paste the following placeholder values:
GOOGLE_CLIENT_ID=your-google-client-id-here
GOOGLE_CLIENT_SECRET=your-google-client-secret-here
MICROSOFT_CLIENT_ID=your-microsoft-client-id-here
MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret-here
MICROSOFT_TENANT=common
JWT_SECRET=your-random-secret-key-at-least-32-characters-long

What are these values?

These are placeholder credentials. You will replace the Google and Microsoft values in the next step when you register your app with each provider.

The JWT_SECRET is a secret key your server uses to sign login tokens. You will generate a real one in the next step too.

  • Save .env.
  • Open database.py and add a placeholder stub so the app can import from it later:
# Placeholder - to be filled in Step 4

def create_db_and_tables():
    pass


def get_session():
    pass
  • Save database.py.
  • Open config.py and add:
# Placeholder - to be filled in next step
  • Save config.py.
  • Open models.py and add:
# Placeholder - to be filled in Step 4
  • Save models.py.
  • Open auth.py and add:
# Placeholder - to be filled in Step 3
  • Save auth.py.
  • Open routers/sso.py and add a minimal router stub:
# Placeholder - to be filled in Step 3
from fastapi import APIRouter

router = APIRouter()
  • Save routers/sso.py.
  • Open routers/profiles.py and add:
# Placeholder - to be filled in Step 4
from fastapi import APIRouter

router = APIRouter()
  • Save routers/profiles.py.
  • Open routers/admin.py and add:
# Placeholder - to be filled in Step 5
from fastapi import APIRouter

router = APIRouter()
  • Save routers/admin.py.

Why do these stubs exist?

Your main.py will import from these files. The stubs give Python something to import so the app starts without errors.

You will replace each placeholder with real code in later steps.

  • Open main.py and paste the application entry point:
from contextlib import asynccontextmanager

from fastapi import FastAPI

from database import create_db_and_tables
from routers import admin, profiles, sso


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Resume Portal API", lifespan=lifespan)

app.include_router(sso.router)
app.include_router(profiles.router)
app.include_router(admin.router)


@app.get("/")
async def root():
    return {"message": "Resume Portal API", "docs": "/docs"}

What does this code do?

  • The lifespan function runs create_db_and_tables() once when the server starts. This ensures the database is ready before handling requests.
  • app.include_router(...) attaches each group of endpoints (SSO, profiles, admin) to the main application.
  • The root endpoint at / returns a welcome message and points users to the docs URL.
  • Save main.py.
Verify the server starts
  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Make sure (venv) appears in the terminal prompt. If it does not, re-run the activate command for your platform.
  • Start the FastAPI development server by running:
fastapi dev main.py

You should see output that includes lines like:

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Application startup complete.
  • Open your browser and visit http://127.0.0.1:8000/docs.

You should see the Swagger UI interactive documentation page with your API title "Resume Portal API" and the root endpoint listed.

  • Go back to your terminal and press Ctrl+C to stop the server.

Server not starting?

  • If you see ModuleNotFoundError, make sure your virtual environment is active (you should see (venv) in the prompt) and re-run the pip install command.
  • If you see ImportError: cannot import name for any of the router files, make sure each router file has the router = APIRouter() line and that routers/__init__.py exists.
  • If the port is already in use, another process is on port 8000. Close it or run fastapi dev main.py --port 8001 instead.

Help me fix my FastAPI server startup error.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files, then move on to the next step.

ⓧ I'd like to double check the full code

GOOGLE_CLIENT_ID=your-google-client-id-here
GOOGLE_CLIENT_SECRET=your-google-client-secret-here
MICROSOFT_CLIENT_ID=your-microsoft-client-id-here
MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret-here
MICROSOFT_TENANT=common
JWT_SECRET=your-random-secret-key-at-least-32-characters-long
from contextlib import asynccontextmanager

from fastapi import FastAPI

from database import create_db_and_tables
from routers import admin, profiles, sso


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Resume Portal API", lifespan=lifespan)

app.include_router(sso.router)
app.include_router(profiles.router)
app.include_router(admin.router)


@app.get("/")
async def root():
    return {"message": "Resume Portal API", "docs": "/docs"}
# Placeholder - to be filled in next step
# Placeholder - to be filled in Step 4

def create_db_and_tables():
    pass


def get_session():
    pass
# Placeholder - to be filled in Step 4
# Placeholder - to be filled in Step 3
# Placeholder - to be filled in Step 3
from fastapi import APIRouter

router = APIRouter()
# Placeholder - to be filled in Step 4
from fastapi import APIRouter

router = APIRouter()
# Placeholder - to be filled in Step 5
from fastapi import APIRouter

router = APIRouter()

Your development environment is ready and the server runs. Next up, you will register your app with Google and Microsoft to get real OAuth credentials for SSO login.

Set Up Google and Microsoft OAuth Credentials

Your project structure is in place and dependencies are installed. Now you need credentials that let users log in with their real Google or Microsoft accounts.

In this step, you will register your app with both Google and Microsoft so they trust your API to receive user information. You will also wire those credentials into your project so the app can read them at runtime.

In this step, get ready to:
  • Create Google OAuth2 credentials in the Google Cloud Console.
  • Register an app in Microsoft Entra ID and create a client secret.
  • Update config.py to load all environment variables.
Create a Google Cloud project and OAuth2 credentials

Google needs to know about your app before it will send user information back to it. You will create a project in the Google Cloud Console, configure an OAuth consent screen, and generate a Client ID and Client Secret.

  • Open your browser and go to console.cloud.google.com.
  • Sign in with your Google account or create a free Google Cloud account if you do not have one.
  • At the top of the page, click the project dropdown and select New Project.
  • Enter Resume Portal as the project name, then click Create.
  • Make sure the new project is selected in the project dropdown at the top.

What is an OAuth consent screen?

The consent screen is what Google shows your users before sending their info to your app. It displays your app name and what data you are requesting. In testing mode, only users you add as test users can log in.

  • In the left navigation menu, go to Google Auth platform, then Branding.
  • If you see a message saying the platform is not configured yet, click Get Started.
  • Under App Information, enter Resume Portal as the App name.
  • Select your email address for User support email.
  • Click Next.
  • Under Audience, select External as the user type.
  • Click Next.
  • Under Contact Information, enter your email address.
  • Click Next.
  • Under Finish, review the Google API Services User Data Policy.
  • Check the I agree box, click Continue, then click Create.

Now add yourself as a test user so you can log in during development.

  • Click Audience in the left sidebar.
  • Under Test users, click Add users.
  • Enter your Google email address and click Save.

Next, create the OAuth credentials your app will use.

  • In the left navigation, go to Google Auth platform, then Clients.
  • Click Create Client.
  • For Application type, select Web application.
  • In the Name field, enter Resume Portal.
  • Under Authorized redirect URIs, click Add URI and enter the following value:
http://localhost:8000/auth/google/callback
  • Click Create.
  • A dialog appears showing your Client ID and Client Secret. Copy both values somewhere safe.

Why is this free?

Google Cloud does not charge for creating OAuth credentials or issuing tokens. The consent screen in testing mode allows up to 100 test users without verification. You will not incur any costs from this step.

Now paste your Google credentials into the .env file.

  • Switch back to VS Code and open .env.
  • Replace your-google-client-id-here with the Client ID you just copied.
  • Replace your-google-client-secret-here with the Client Secret you just copied.
  • Save .env.

Can not find the Client Secret later?

Google only shows the Client Secret once in the creation dialog. If you closed it without copying, go back to Clients, click on your OAuth client, and click Reset Secret to generate a new one.

Help me find or reset my Google OAuth Client Secret.

Register an app in Microsoft Entra ID

Microsoft uses Entra ID (formerly Azure Active Directory) to manage app registrations. You will create a free Azure account, register your app, and generate a client secret.

  • Open your browser and go to portal.azure.com.
  • Sign in with your Microsoft account or create a free Azure account if you do not have one.

Navigate to the Microsoft Entra admin center to register your app.

  • In the portal search bar at the top, type Entra and select Microsoft Entra ID.
  • In the left sidebar, browse to Identity, then Applications, then App registrations.
  • Click New registration.
  • For Name, enter Resume Portal.
  • Under Supported account types, select Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts.
  • Under Redirect URI, select Web from the dropdown and enter the following value:
http://localhost:8000/auth/microsoft/callback
  • Click Register.

You are now on the app's Overview page. Copy the two IDs you need.

  • Copy the Application (client) ID value.
  • Copy the Directory (tenant) ID value. You can use this or leave MICROSOFT_TENANT as common in your .env to allow all account types.

Now create a client secret for the app.

  • In the left sidebar of your app registration, click Certificates & secrets.
  • Click the Client secrets tab, then click New client secret.
  • Enter a description like dev secret and choose an expiration period.
  • Click Add.
  • Copy the Value column immediately. This secret is only displayed once.

Copy the secret value now

Microsoft Entra only displays the client secret value at the moment of creation. If you navigate away without copying it, you will need to create a new secret. Copy and paste it into your .env file right away.

  • Switch back to VS Code and open .env.
  • Replace your-microsoft-client-id-here with the Application (client) ID you copied.
  • Replace your-microsoft-client-secret-here with the secret Value you just copied.
  • Leave MICROSOFT_TENANT=common as-is to allow both personal and organizational Microsoft accounts.
  • Save .env.

Why is this free?

Microsoft does not charge for app registrations or token issuance in Entra ID. You can register apps and authenticate users at no cost.

Having trouble with the Azure portal?

Make sure you are signed in with a personal Microsoft account (Outlook, Hotmail, or Live). If you are using an organizational account, your admin may have restricted app registrations.

Help me troubleshoot my Microsoft Entra app registration.

Verify credentials are loaded

Your .env file now has real credentials, but your app does not read them yet. The config.py file is still a placeholder. You will update it to load environment variables using python-dotenv and expose them as module-level constants.

  • Open config.py in VS Code.
  • Delete the placeholder comment and replace the entire file with the following code:
import os
from dotenv import load_dotenv

# Load environment variables from the .env file
load_dotenv()

# Google OAuth credentials
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]

# Microsoft OAuth credentials
MICROSOFT_CLIENT_ID = os.environ["MICROSOFT_CLIENT_ID"]
MICROSOFT_CLIENT_SECRET = os.environ["MICROSOFT_CLIENT_SECRET"]
MICROSOFT_TENANT = os.environ.get("MICROSOFT_TENANT", "common")

# JWT configuration
JWT_SECRET = os.environ["JWT_SECRET"]
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24

What does this code do?

The load_dotenv() call reads your .env file and injects those values into environment variables. Then os.environ["KEY"] retrieves each one. If a required variable is missing, the app crashes immediately with a clear error instead of failing silently later.

The JWT_ALGORITHM and JWT_EXPIRATION_HOURS constants are not secrets. They configure how your app will sign and validate login tokens in a later step.

  • Save config.py.
  • Verify the config loads correctly by running this command in your terminal:
python -c "import config; print('Google ID loaded:', config.GOOGLE_CLIENT_ID[:10] + '...'); print('Microsoft ID loaded:', config.MICROSOFT_CLIENT_ID[:10] + '...'); print('All config loaded successfully')"

You should see the first 10 characters of each ID printed, followed by the success message. This confirms all environment variables loaded without errors.

Seeing a KeyError?

A KeyError means one of the required environment variables is missing from your .env file. Check that all six keys exist and have values after the = sign: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, MICROSOFT_TENANT, and JWT_SECRET.

Help me fix this KeyError in config.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved config.py and that your .env has real credential values (not the original placeholders).

ⓧ I'd like to double check the full code

Here is what your config.py should look like:

import os
from dotenv import load_dotenv

load_dotenv()

GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
MICROSOFT_CLIENT_ID = os.environ["MICROSOFT_CLIENT_ID"]
MICROSOFT_CLIENT_SECRET = os.environ["MICROSOFT_CLIENT_SECRET"]
MICROSOFT_TENANT = os.environ.get("MICROSOFT_TENANT", "common")
JWT_SECRET = os.environ["JWT_SECRET"]
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24

And your .env should have your real credentials in place of the placeholders (the exact values will be unique to your accounts):

GOOGLE_CLIENT_ID=your-actual-google-client-id
GOOGLE_CLIENT_SECRET=your-actual-google-client-secret
MICROSOFT_CLIENT_ID=your-actual-microsoft-client-id
MICROSOFT_CLIENT_SECRET=your-actual-microsoft-client-secret
MICROSOFT_TENANT=common
JWT_SECRET=your-random-secret-key-at-least-32-characters-long

Your OAuth credentials are in place and your app can read them. Next up, you will use these credentials to build the actual SSO login endpoints that redirect users to Google and Microsoft.

Build SSO Login Endpoints

You have OAuth credentials from Google and Microsoft. Now you need to build the endpoints that redirect users to those login pages and handle the response when they come back authenticated.

The flow works like this: your API sends the user to Google or Microsoft, the user logs in there, and the provider redirects back to your API with the user's info. Your API then creates a JSON Web Token (JWT) so the user stays logged in for future requests.

In this step, get ready to:
  • Build database models and a connection layer for storing users.
  • Create JWT token generation and validation functions.
  • Implement SSO login and callback endpoints for Google and Microsoft.
Implement Google and Microsoft SSO routes

Before you can write the SSO endpoints, you need the database layer they depend on. The SSO callbacks will look up or create a user in the database after authentication succeeds.

  • Open models.py in VS Code and replace the placeholder with the following code to define the User and Profile models:
from datetime import datetime
from enum import Enum
from typing import Optional

from sqlmodel import Field, SQLModel


# Defines the three permission levels for users
class RoleEnum(str, Enum):
    user = "user"
    admin = "admin"
    super_admin = "super_admin"


# Tracks the review state of profile submissions
class ProfileStatus(str, Enum):
    pending = "pending"
    approved = "approved"
    rejected = "rejected"


# Stores each authenticated user's identity and role
class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str = Field(unique=True, index=True)
    name: str
    provider: str
    role: RoleEnum = Field(default=RoleEnum.user)
    created_at: datetime = Field(default_factory=datetime.utcnow)

What does this code do?

RoleEnum defines three permission levels: regular user, admin, and super_admin. Every new user starts as user by default.

The User model maps to a database table. It stores the user's email, display name, which OAuth provider they used (Google or Microsoft), and their role.

  • Still in models.py, add the Profile model and submission schema below the User class:
# Holds submitted profile details and their review status
class Profile(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key="user.id", index=True)
    bio: str
    skills: str
    experience: str
    status: ProfileStatus = Field(default=ProfileStatus.pending)
    submitted_at: datetime = Field(default_factory=datetime.utcnow)
    reviewed_at: Optional[datetime] = None
    reviewed_by: Optional[int] = None


# Schema for incoming profile submission requests (no table)
class ProfileSubmission(SQLModel):
    bio: str
    skills: str
    experience: str

What does this code do?

Profile stores a user's bio, skills, and experience along with an approval status. Admins will review these later.

ProfileSubmission is a Pydantic model (no table=True) used only for request body validation. It defines what fields a user must send when submitting a profile.

  • Save models.py.
  • Open database.py and replace the placeholder with this database connection setup:
from sqlmodel import Session, SQLModel, create_engine

# SQLite stores everything in a single local file
DATABASE_URL = "sqlite:///./resume_portal.db"

# echo=True prints SQL statements to the terminal for debugging
engine = create_engine(DATABASE_URL, echo=True)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session

What does this code do?

create_engine creates a connection to a SQLite database file called resume_portal.db. The file will appear in your project folder automatically.

get_session is a FastAPI dependency that provides a database session to each request. It opens a connection, lets the endpoint use it, then closes it automatically.

  • Save database.py.

Now you have the database layer ready. Time to build the SSO router that uses it.

  • Open routers/sso.py and replace the placeholder with the following imports and SSO client configuration:
import os
from typing import Annotated

from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlmodel import Session, select

from auth import create_access_token
from config import (
    GOOGLE_CLIENT_ID,
    GOOGLE_CLIENT_SECRET,
    MICROSOFT_CLIENT_ID,
    MICROSOFT_CLIENT_SECRET,
    MICROSOFT_TENANT,
)
from database import get_session
from fastapi_sso.sso.google import GoogleSSO
from fastapi_sso.sso.microsoft import MicrosoftSSO
from models import User

router = APIRouter(prefix="/auth", tags=["Authentication"])

# Configure the Google SSO client with your OAuth credentials
google_sso = GoogleSSO(
    client_id=GOOGLE_CLIENT_ID,
    client_secret=GOOGLE_CLIENT_SECRET,
    redirect_uri="http://localhost:8000/auth/google/callback",
    allow_insecure_http=True,
)

# Configure the Microsoft SSO client with your OAuth credentials
microsoft_sso = MicrosoftSSO(
    client_id=MICROSOFT_CLIENT_ID,
    client_secret=MICROSOFT_CLIENT_SECRET,
    tenant=MICROSOFT_TENANT,
    redirect_uri="http://localhost:8000/auth/microsoft/callback",
    allow_insecure_http=True,
)

What does this code do?

GoogleSSO and MicrosoftSSO are helper classes from the fastapi-sso library. They handle the OAuth2 redirect flow for you. You pass in your client credentials and the callback URL that matches what you registered in the previous step.

allow_insecure_http=True is required because localhost uses HTTP instead of HTTPS. OAuth normally requires HTTPS, but this flag relaxes that requirement for local development.

  • Below the SSO client setup in routers/sso.py, add the Google login and callback endpoints:
@router.get("/google/login")
async def google_login():
    async with google_sso:
        return await google_sso.get_login_redirect(
            params={"prompt": "consent", "access_type": "offline"}
        )


@router.get("/google/callback")
async def google_callback(
    request: Request, session: Annotated[Session, Depends(get_session)]
):
    async with google_sso:
        user_info = await google_sso.verify_and_process(request)

    # Check if this user already exists in the database
    db_user = session.exec(
        select(User).where(User.email == user_info.email)
    ).first()

    # Create a new user record if this is their first login
    if not db_user:
        db_user = User(
            email=user_info.email,
            name=user_info.display_name or user_info.email,
            provider="google",
        )
        session.add(db_user)
        session.commit()
        session.refresh(db_user)

    # Issue a JWT token so the user stays authenticated
    token = create_access_token(db_user.email, db_user.role.value)
    return JSONResponse({"access_token": token, "token_type": "bearer"})

What does this code do?

The /google/login endpoint redirects the user's browser to Google's consent screen. The async with google_sso: pattern is required by fastapi-sso to prevent race conditions.

The /google/callback endpoint runs after Google redirects back. It verifies the response, looks up (or creates) the user in your database, then returns a JWT token the user can use for future API calls.

  • Below the Google endpoints in routers/sso.py, add the Microsoft login and callback endpoints:
@router.get("/microsoft/login")
async def microsoft_login():
    async with microsoft_sso:
        return await microsoft_sso.get_login_redirect()


@router.get("/microsoft/callback")
async def microsoft_callback(
    request: Request, session: Annotated[Session, Depends(get_session)]
):
    async with microsoft_sso:
        user_info = await microsoft_sso.verify_and_process(request)

    # Check if this user already exists in the database
    db_user = session.exec(
        select(User).where(User.email == user_info.email)
    ).first()

    # Create a new user record if this is their first login
    if not db_user:
        db_user = User(
            email=user_info.email,
            name=user_info.display_name or user_info.email,
            provider="microsoft",
        )
        session.add(db_user)
        session.commit()
        session.refresh(db_user)

    # Issue a JWT token so the user stays authenticated
    token = create_access_token(db_user.email, db_user.role.value)
    return JSONResponse({"access_token": token, "token_type": "bearer"})

What does this code do?

The Microsoft endpoints follow the exact same pattern as Google. The only differences are the SSO client used and the provider value stored in the database ("microsoft" instead of "google").

The tenant is set to common in your config, which allows both personal Microsoft accounts (Outlook/Hotmail) and organizational accounts to log in.

  • Save routers/sso.py.

Seeing import errors in your editor?

Your editor might show red underlines on from auth import create_access_token. This is expected because you have not written auth.py yet. The error will disappear after the next substep.

help me fix import errors in my SSO router file

Create JWT token generation

Your SSO callbacks need a way to create tokens that prove a user is logged in. A JWT encodes the user's email and role into a signed string that your API can verify on every request.

  • Open auth.py and replace the placeholder with the token creation function:
from datetime import datetime, timedelta
from typing import Annotated

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from config import JWT_ALGORITHM, JWT_EXPIRATION_HOURS, JWT_SECRET
from models import RoleEnum

# Tells FastAPI to look for a Bearer token in the Authorization header
security = HTTPBearer()


def create_access_token(email: str, role: str) -> str:
    # Pack the user's identity and an expiration time into the token
    payload = {
        "sub": email,
        "role": role,
        "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS),
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

What does this code do?

create_access_token builds a JWT containing the user's email (sub), their role, and an expiration timestamp. The token is signed with your JWT_SECRET using the HS256 algorithm so nobody can tamper with it.

HTTPBearer() tells FastAPI to expect a Bearer token in the Authorization header of incoming requests. This enables the lock icon in the Swagger docs.

  • Below create_access_token in auth.py, add the token validation dependency and role checker:
def get_current_user(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
) -> dict:
    token = credentials.credentials
    try:
        # Decode and verify the JWT signature and expiration
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        return {"email": payload["sub"], "role": payload["role"]}
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired"
        )
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
        )


def require_role(allowed_roles: list[RoleEnum]):
    def role_checker(
        current_user: Annotated[dict, Depends(get_current_user)],
    ) -> dict:
        if current_user["role"] not in [r.value for r in allowed_roles]:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions",
            )
        return current_user

    return role_checker

What does this code do?

get_current_user is a FastAPI dependency. Any endpoint that includes it will require a valid JWT. It extracts the token from the request header, decodes it, and returns the user's email and role.

require_role is a factory function that creates dependencies for specific permission levels. For example, you can restrict admin endpoints to only users with the admin or super_admin role.

  • Save auth.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved both auth.py and routers/sso.py.

ⓧ I'd like to double check the full code

Here is the complete auth.py:

from datetime import datetime, timedelta
from typing import Annotated

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from config import JWT_ALGORITHM, JWT_EXPIRATION_HOURS, JWT_SECRET
from models import RoleEnum

security = HTTPBearer()


def create_access_token(email: str, role: str) -> str:
    payload = {
        "sub": email,
        "role": role,
        "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS),
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)


def get_current_user(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
) -> dict:
    token = credentials.credentials
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        return {"email": payload["sub"], "role": payload["role"]}
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired"
        )
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
        )


def require_role(allowed_roles: list[RoleEnum]):
    def role_checker(
        current_user: Annotated[dict, Depends(get_current_user)],
    ) -> dict:
        if current_user["role"] not in [r.value for r in allowed_roles]:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions",
            )
        return current_user

    return role_checker

Here is the complete routers/sso.py:

import os
from typing import Annotated

from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlmodel import Session, select

from auth import create_access_token
from config import (
    GOOGLE_CLIENT_ID,
    GOOGLE_CLIENT_SECRET,
    MICROSOFT_CLIENT_ID,
    MICROSOFT_CLIENT_SECRET,
    MICROSOFT_TENANT,
)
from database import get_session
from fastapi_sso.sso.google import GoogleSSO
from fastapi_sso.sso.microsoft import MicrosoftSSO
from models import User

router = APIRouter(prefix="/auth", tags=["Authentication"])

google_sso = GoogleSSO(
    client_id=GOOGLE_CLIENT_ID,
    client_secret=GOOGLE_CLIENT_SECRET,
    redirect_uri="http://localhost:8000/auth/google/callback",
    allow_insecure_http=True,
)

microsoft_sso = MicrosoftSSO(
    client_id=MICROSOFT_CLIENT_ID,
    client_secret=MICROSOFT_CLIENT_SECRET,
    tenant=MICROSOFT_TENANT,
    redirect_uri="http://localhost:8000/auth/microsoft/callback",
    allow_insecure_http=True,
)


@router.get("/google/login")
async def google_login():
    async with google_sso:
        return await google_sso.get_login_redirect(
            params={"prompt": "consent", "access_type": "offline"}
        )


@router.get("/google/callback")
async def google_callback(
    request: Request, session: Annotated[Session, Depends(get_session)]
):
    async with google_sso:
        user_info = await google_sso.verify_and_process(request)

    db_user = session.exec(
        select(User).where(User.email == user_info.email)
    ).first()

    if not db_user:
        db_user = User(
            email=user_info.email,
            name=user_info.display_name or user_info.email,
            provider="google",
        )
        session.add(db_user)
        session.commit()
        session.refresh(db_user)

    token = create_access_token(db_user.email, db_user.role.value)
    return JSONResponse({"access_token": token, "token_type": "bearer"})


@router.get("/microsoft/login")
async def microsoft_login():
    async with microsoft_sso:
        return await microsoft_sso.get_login_redirect()


@router.get("/microsoft/callback")
async def microsoft_callback(
    request: Request, session: Annotated[Session, Depends(get_session)]
):
    async with microsoft_sso:
        user_info = await microsoft_sso.verify_and_process(request)

    db_user = session.exec(
        select(User).where(User.email == user_info.email)
    ).first()

    if not db_user:
        db_user = User(
            email=user_info.email,
            name=user_info.display_name or user_info.email,
            provider="microsoft",
        )
        session.add(db_user)
        session.commit()
        session.refresh(db_user)

    token = create_access_token(db_user.email, db_user.role.value)
    return JSONResponse({"access_token": token, "token_type": "bearer"})

Here is the complete models.py:

from datetime import datetime
from enum import Enum
from typing import Optional

from sqlmodel import Field, SQLModel


class RoleEnum(str, Enum):
    user = "user"
    admin = "admin"
    super_admin = "super_admin"


class ProfileStatus(str, Enum):
    pending = "pending"
    approved = "approved"
    rejected = "rejected"


class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str = Field(unique=True, index=True)
    name: str
    provider: str
    role: RoleEnum = Field(default=RoleEnum.user)
    created_at: datetime = Field(default_factory=datetime.utcnow)


class Profile(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key="user.id", index=True)
    bio: str
    skills: str
    experience: str
    status: ProfileStatus = Field(default=ProfileStatus.pending)
    submitted_at: datetime = Field(default_factory=datetime.utcnow)
    reviewed_at: Optional[datetime] = None
    reviewed_by: Optional[int] = None


class ProfileSubmission(SQLModel):
    bio: str
    skills: str
    experience: str

Here is the complete database.py:

from sqlmodel import Session, SQLModel, create_engine

DATABASE_URL = "sqlite:///./resume_portal.db"

engine = create_engine(DATABASE_URL, echo=True)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session
Connect SSO callbacks to JWT issuance and test the flow

All the pieces are in place. Your main.py already imports the routers and calls create_db_and_tables() on startup. Time to restart the server and test the full login flow.

  • Switch back to your terminal.
  • If the dev server is still running, press Ctrl+C to stop it.
  • Restart the server by running:
fastapi dev main.py
  • You should see Application startup complete in the terminal output along with SQL statements showing the database tables being created.

Why do I see SQL in the terminal?

The echo=True setting in database.py prints every SQL statement the app executes. On first startup, you will see CREATE TABLE statements for the user and profile tables. This is helpful for debugging.

  • Open your browser and navigate to http://localhost:8000/auth/google/login.

Your browser should redirect to Google's consent screen asking you to choose an account and grant permissions. If you see the Google login page, your SSO endpoints are working correctly.

What happens during the SSO flow?

When you visit /auth/google/login, your API tells the browser to redirect to Google. After you sign in at Google, Google redirects back to /auth/google/callback with a code. Your callback endpoint exchanges that code for the user's email and name, creates a database record, and returns a JWT.

Not redirecting to Google?

Check that your .env file has the correct GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET values from the Google Cloud Console.

Make sure the redirect URI in your Google OAuth client settings is exactly http://localhost:8000/auth/google/callback with no trailing slash.

help me debug why my Google SSO login redirect is not working

Your SSO login flow is live. Next up, you will build the profile submission endpoints so authenticated users can submit their details for review.

Add Database Models and Profile Submission

Your SSO login flow is working and the database already stores user records on first sign-in. The next piece is letting those authenticated users submit their profile details for admin review.

In this step, you'll build the profile submission endpoints that let users send their bio, skills, and experience. You'll also add an endpoint for users to check their own submission status.

In this step, get ready to:
  • Review the database models that support profile submission.
  • Build profile submission and retrieval endpoints.
  • Test the full profile flow using Swagger UI with your JWT token.
Review the existing database models

Your models.py file already defines three models that power the profile submission workflow. These were set up earlier alongside the User model that your SSO endpoints depend on.

  • Open models.py in VS Code and review the models inside.

What do these models represent?

  • User stores each person who logs in via SSO. It tracks their email, name, OAuth provider, and role (user, admin, or super_admin).
  • Profile holds a user's submitted bio, skills, and experience. Its status field tracks whether the submission is pending, approved, or rejected.
  • ProfileSubmission is a validation schema (not a database table). It defines the shape of data users send when submitting a profile.

The database.py file provides a get_session dependency that gives each endpoint a fresh database session. Your main.py already calls create_db_and_tables() on startup, so the tables exist in resume_portal.db.

Build the profile submission endpoint

Authenticated users need a way to submit their profile details for review. The endpoint should enforce that each user can only have one active submission at a time.

  • Open routers/profiles.py in VS Code.
  • Replace the placeholder comment with the following code:
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select

from auth import get_current_user
from database import get_session
from models import Profile, ProfileSubmission, User

router = APIRouter(prefix="/profiles", tags=["Profiles"])


@router.post("/submit")
async def submit_profile(
    data: ProfileSubmission,
    current_user: Annotated[dict, Depends(get_current_user)],
    session: Annotated[Session, Depends(get_session)],
):
    # Look up the user record by their email from the JWT
    db_user = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    # Check if the user already submitted a profile
    existing = session.exec(
        select(Profile).where(Profile.user_id == db_user.id)
    ).first()

    if existing:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="You already have a profile submission. Wait for review.",
        )

    # Create a new profile with pending status
    profile = Profile(
        user_id=db_user.id,
        bio=data.bio,
        skills=data.skills,
        experience=data.experience,
    )
    session.add(profile)
    session.commit()
    session.refresh(profile)
    return {"message": "Profile submitted successfully", "profile_id": profile.id}

What does this code do?

  • The imports bring in get_current_user (the JWT dependency from auth.py) and get_session (the database session dependency from database.py).
  • The get_current_user dependency means this endpoint requires a valid JWT. Unauthenticated requests get a 401 error automatically.
  • Before creating a profile, the endpoint checks if the user already has one. This enforces the one-submission-per-user rule.
  • New profiles get the default pending status, which means they need an admin to approve them before they're considered active.
Add the profile retrieval endpoint

Users also need a way to check the status of their own submission. This endpoint returns the user's profile or a 404 if they haven't submitted one yet.

  • In routers/profiles.py, add the following code below the submit_profile function:
@router.get("/me")
async def get_my_profile(
    current_user: Annotated[dict, Depends(get_current_user)],
    session: Annotated[Session, Depends(get_session)],
):
    # Find the user record from the JWT email
    db_user = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    # Look up the user's profile submission
    profile = session.exec(
        select(Profile).where(Profile.user_id == db_user.id)
    ).first()

    if not profile:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No profile submission found",
        )

    return profile

What does this code do?

  • This endpoint uses the same get_current_user dependency, so only logged-in users can access it.
  • It returns the full Profile object including the status field, so users can see whether their submission is pending, approved, or rejected.
  • If the user hasn't submitted a profile yet, they get a clear 404 response instead of an empty result.
  • Save routers/profiles.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved routers/profiles.py before moving on.

ⓧ I'd like to double check the full code

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select

from auth import get_current_user
from database import get_session
from models import Profile, ProfileSubmission, User

router = APIRouter(prefix="/profiles", tags=["Profiles"])


@router.post("/submit")
async def submit_profile(
    data: ProfileSubmission,
    current_user: Annotated[dict, Depends(get_current_user)],
    session: Annotated[Session, Depends(get_session)],
):
    db_user = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    existing = session.exec(
        select(Profile).where(Profile.user_id == db_user.id)
    ).first()

    if existing:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="You already have a profile submission. Wait for review.",
        )

    profile = Profile(
        user_id=db_user.id,
        bio=data.bio,
        skills=data.skills,
        experience=data.experience,
    )
    session.add(profile)
    session.commit()
    session.refresh(profile)
    return {"message": "Profile submitted successfully", "profile_id": profile.id}


@router.get("/me")
async def get_my_profile(
    current_user: Annotated[dict, Depends(get_current_user)],
    session: Annotated[Session, Depends(get_session)],
):
    db_user = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    profile = session.exec(
        select(Profile).where(Profile.user_id == db_user.id)
    ).first()

    if not profile:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="No profile submission found",
        )

    return profile

Server won't start?

  • Check that your import line says from models import Profile, ProfileSubmission, User. A missing import causes an ImportError on startup.
  • Make sure routers/__init__.py exists (even if empty). Python needs this file to treat routers/ as a package.

Help me debug my profiles router import error

Test the profile submission flow

With both endpoints in place, you can test the full workflow. You'll log in via SSO to get a token, then use the Swagger UI to submit a profile.

  • Start the development server by running:
fastapi dev main.py
  • Open your browser and navigate to http://localhost:8000/auth/google/login (or the Microsoft login URL if you prefer).
  • Complete the sign-in flow.
  • Copy the access_token value from the JSON response in your browser.

Where is my token?

After the OAuth callback completes, your browser shows a JSON response like {"access_token": "eyJ...", "token_type": "bearer"}. Copy the long string after access_token (without the quotes).

  • Navigate to http://127.0.0.1:8000/docs to open the Swagger UI.
  • Click the Authorize button (the lock icon at the top right of the page).
  • In the Value field, paste your token and click Authorize.
  • Expand the POST /profiles/submit endpoint and click Try it out.
  • Enter some test data in the request body:
{
  "bio": "Software engineer with 3 years of experience",
  "skills": "Python, FastAPI, React",
  "experience": "Built REST APIs and web applications"
}
  • Click Execute.
  • Verify the response shows "message": "Profile submitted successfully" with a profile_id.
  • Now expand GET /profiles/me, click Try it out, then Execute.
  • Verify the response returns your profile with "status": "pending".

Why is the status pending?

Every new profile submission starts with pending status. This means an admin still needs to review and approve it. You'll build the admin approval endpoints in the next step.

Getting a 401 Unauthorized error?

  • Make sure you clicked Authorize in Swagger UI and pasted your full token (the long eyJ... string, not the entire JSON response).
  • Tokens expire after 24 hours. If you logged in earlier, try the SSO login again to get a fresh token.
  • Check that JWT_SECRET in your .env file hasn't changed since you logged in.

Help me fix my 401 Unauthorized error when testing profile endpoints

Your users can now submit profiles and check their status. Next up, you'll build the admin endpoints that let authorized users approve or reject these submissions.

Build the Admin Approval Workflow

Users can submit their profiles, but those submissions currently sit in a "pending" state with nobody to review them. Every real-world application that accepts user-generated content needs an admin layer to moderate it.

In this step, you'll build protected admin endpoints that let authorized users list all pending profiles, approve them, or reject them. You'll also promote a user to admin in the database and test the full workflow.

In this step, get ready to:
  • Create a role-checking dependency for admin access.
  • Build endpoints to list, approve, and reject profiles.
  • Test the admin workflow by promoting a user and reviewing profiles.
Create the role-checking dependency

In the previous step, you wrote a require_role function in auth.py that checks whether a user's JWT contains one of the allowed roles. Now you'll use it in a new admin router to gate all admin endpoints behind a single dependency.

  • In VS Code, open routers/admin.py and replace the placeholder comment with the following imports and router setup:
from datetime import datetime
from typing import Annotated, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select

from auth import require_role
from database import get_session
from models import Profile, ProfileStatus, RoleEnum, User

router = APIRouter(prefix="/admin", tags=["Admin"])

# Create a reusable dependency that only allows admin or super_admin roles
admin_or_super = require_role([RoleEnum.admin, RoleEnum.super_admin])

What does this code do?

The key line is admin_or_super = require_role([RoleEnum.admin, RoleEnum.super_admin]). This creates a reusable FastAPI dependency that you'll attach to every admin endpoint.

When a request arrives, FastAPI calls this dependency automatically. It checks the JWT token's role claim against the allowed list. If the role is not admin or super_admin, it returns a 403 Forbidden response before your endpoint code even runs.

  • Save routers/admin.py.
Build admin endpoints

You need three endpoints for the admin workflow: one to list profiles by status, one to approve a profile, and one to reject it. Each endpoint uses the admin_or_super dependency to enforce access control.

  • Add the list profiles endpoint below admin_or_super in routers/admin.py:
@router.get("/profiles")
async def list_profiles(
    current_user: Annotated[dict, Depends(admin_or_super)],
    session: Annotated[Session, Depends(get_session)],
    status_filter: Optional[ProfileStatus] = Query(None, alias="status"),
):
    # Build a query that optionally filters by profile status
    query = select(Profile)
    if status_filter:
        query = query.where(Profile.status == status_filter)
    profiles = session.exec(query).all()
    return profiles

What does this endpoint do?

GET /admin/profiles returns all profile submissions. The optional status query parameter lets admins filter by pending, approved, or rejected.

The Query(None, alias="status") part means the URL looks like /admin/profiles?status=pending while the Python variable is named status_filter to avoid shadowing Python's built-in status module.

  • Save routers/admin.py.
  • You won't see a change yet. The next chunk adds the approve endpoint that completes the workflow.
  • Add the approve endpoint below list_profiles in routers/admin.py:
@router.patch("/profiles/{profile_id}/approve")
async def approve_profile(
    profile_id: int,
    current_user: Annotated[dict, Depends(admin_or_super)],
    session: Annotated[Session, Depends(get_session)],
):
    # Look up the profile by its ID
    profile = session.get(Profile, profile_id)
    if not profile:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
        )

    # Find the reviewer's user record to store who approved it
    reviewer = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    # Update the profile status and record the review metadata
    profile.status = ProfileStatus.approved
    profile.reviewed_at = datetime.utcnow()
    profile.reviewed_by = reviewer.id
    session.add(profile)
    session.commit()
    session.refresh(profile)
    return {"message": "Profile approved", "profile": profile}

What does this endpoint do?

PATCH /admin/profiles/{profile_id}/approve changes a profile's status from pending to approved. It also records which admin approved it and when.

The session.get(Profile, profile_id) call fetches a profile by primary key. If no profile exists with that ID, the endpoint returns a 404 error. This protects against invalid IDs.

  • Save routers/admin.py.
  • You won't see a change yet. The reject endpoint follows the same pattern.
  • Add the reject endpoint below approve_profile in routers/admin.py:
@router.patch("/profiles/{profile_id}/reject")
async def reject_profile(
    profile_id: int,
    current_user: Annotated[dict, Depends(admin_or_super)],
    session: Annotated[Session, Depends(get_session)],
):
    # Look up the profile by its ID
    profile = session.get(Profile, profile_id)
    if not profile:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
        )

    # Find the reviewer's user record to store who rejected it
    reviewer = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    # Update the profile status and record the review metadata
    profile.status = ProfileStatus.rejected
    profile.reviewed_at = datetime.utcnow()
    profile.reviewed_by = reviewer.id
    session.add(profile)
    session.commit()
    session.refresh(profile)
    return {"message": "Profile rejected", "profile": profile}

What does this endpoint do?

PATCH /admin/profiles/{profile_id}/reject works identically to the approve endpoint, except it sets the status to rejected. Both endpoints record the reviewer's user ID and a timestamp so you always have an audit trail.

  • Save routers/admin.py.
  • Return to your terminal and confirm the server is still running with no import errors. If you stopped it, start it again by running:
fastapi dev main.py

Seeing an import error?

Make sure you imported Query from fastapi and Optional from typing in the import section at the top of routers/admin.py. Also confirm ProfileStatus is listed in the import from models.

Help me debug an import error in my admin router.

✔️ Awesome, I've got everything!

Great. Double check you've saved routers/admin.py and the server is running without errors.

ⓧ I'd like to double check the full code

from datetime import datetime
from typing import Annotated, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select

from auth import require_role
from database import get_session
from models import Profile, ProfileStatus, RoleEnum, User

router = APIRouter(prefix="/admin", tags=["Admin"])

admin_or_super = require_role([RoleEnum.admin, RoleEnum.super_admin])


@router.get("/profiles")
async def list_profiles(
    current_user: Annotated[dict, Depends(admin_or_super)],
    session: Annotated[Session, Depends(get_session)],
    status_filter: Optional[ProfileStatus] = Query(None, alias="status"),
):
    query = select(Profile)
    if status_filter:
        query = query.where(Profile.status == status_filter)
    profiles = session.exec(query).all()
    return profiles


@router.patch("/profiles/{profile_id}/approve")
async def approve_profile(
    profile_id: int,
    current_user: Annotated[dict, Depends(admin_or_super)],
    session: Annotated[Session, Depends(get_session)],
):
    profile = session.get(Profile, profile_id)
    if not profile:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
        )

    reviewer = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    profile.status = ProfileStatus.approved
    profile.reviewed_at = datetime.utcnow()
    profile.reviewed_by = reviewer.id
    session.add(profile)
    session.commit()
    session.refresh(profile)
    return {"message": "Profile approved", "profile": profile}


@router.patch("/profiles/{profile_id}/reject")
async def reject_profile(
    profile_id: int,
    current_user: Annotated[dict, Depends(admin_or_super)],
    session: Annotated[Session, Depends(get_session)],
):
    profile = session.get(Profile, profile_id)
    if not profile:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
        )

    reviewer = session.exec(
        select(User).where(User.email == current_user["email"])
    ).first()

    profile.status = ProfileStatus.rejected
    profile.reviewed_at = datetime.utcnow()
    profile.reviewed_by = reviewer.id
    session.add(profile)
    session.commit()
    session.refresh(profile)
    return {"message": "Profile rejected", "profile": profile}
Test the admin workflow

Your admin endpoints are locked behind the admin_or_super dependency, which means no user can access them yet. You need to manually promote one of your test users to the admin role directly in the SQLite database.

  • Open a new terminal tab (keep your server running in the other one).
  • Make sure you are in the resume-portal project folder.
  • Promote your first user to admin by running this command:
sqlite3 resume_portal.db "UPDATE user SET role='admin' WHERE id=1;"

What does this command do?

This directly updates the role column of the user with ID 1 in your SQLite database file. It bypasses the API entirely to give that user admin privileges.

In production, you would never do this manually. You would have a super admin panel or a database migration. For development and testing, this shortcut is perfectly fine.

  • Verify the update worked by running:
sqlite3 resume_portal.db "SELECT id, email, role FROM user;"

You should see your user's role listed as admin in the output.

Command not found?

If sqlite3 is not available on your system, you can use Python's built-in sqlite3 module instead:

python3 -c "import sqlite3; c=sqlite3.connect('resume_portal.db'); c.execute(\"UPDATE user SET role='admin' WHERE id=1\"); c.commit(); print('Updated')"

Help me promote a user to admin in my SQLite database.

Now you need a fresh JWT that contains the admin role. Your current token still says user because the role is baked into the token at login time.

  • Open your browser and navigate to http://localhost:8000/auth/google/login (or the Microsoft equivalent if you used that provider).
  • Sign in with the same account you used before.
  • Copy the access_token value from the JSON response.
  • Open the Swagger UI at http://127.0.0.1:8000/docs.
  • Click the Authorize button (green lock icon at the top right).
  • Paste your new token into the Value field and click Authorize.

Why log in again?

JWTs are self-contained. The role is encoded inside the token when it's created. Updating the database does not change existing tokens. You must log in again so the callback handler reads the new role from the database and issues a fresh token with role: admin.

  • In the Swagger UI, expand GET /admin/profiles and click Try it out.
  • Leave the status field empty and click Execute.

You should see a 200 response containing a list of all profiles in the database.

  • Now expand PATCH /admin/profiles/{profile_id}/approve and click Try it out.
  • Enter 1 in the profile_id field.
  • Click Execute.

You should see a 200 response with "message": "Profile approved" and the profile object showing status: approved, reviewed_at, and reviewed_by populated.

Verify the 403 response too

Try removing your token from Swagger (click Authorize then Logout) and calling the admin endpoint again. You should get a 403 Forbidden, proving the role check works.

Getting a 403 even after logging in?

This means your JWT still contains the old user role. Make sure you logged in again via the SSO URL after updating the database. The token from before the database update will not work.

Also confirm the sqlite3 UPDATE command actually changed the role. Run the SELECT query again and verify it says admin.

Help me debug why I'm getting a 403 on my admin endpoint.

Your admin approval workflow is complete. Users submit profiles, admins review them, and every decision is recorded with a timestamp and reviewer ID. Next up, you'll extend this system with a secret mission that adds rejection reasons and a super-admin-only promotion endpoint.

Secret mission

Add Reject with Reason and Role Promotion

Right now, admins can reject profiles but can't tell users why. And there's no way to promote trusted users to admin status without manually editing the database. Time to fix both.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a SQLite database, so there are no ongoing costs. The Google Cloud and Microsoft Entra credentials you created are also free to maintain.

Resources you used:

  • Project folder on your Desktop (Python files, virtual environment, SQLite database).
  • Google Cloud OAuth2 client credentials.
  • Microsoft Entra app registration.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project soon.

  • The FastAPI server only runs when you start it manually with fastapi dev main.py. There is no background process consuming resources.
  • Your Google Cloud and Microsoft Entra credentials remain available for future projects at no cost.
  • The SQLite database (resume_portal.db) is a local file that takes minimal disk space.

Pause - I'll come back to this later

Stop any running processes to free up terminal resources. Your files and credentials stay intact for when you return.

  • If your FastAPI dev server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate your Python virtual environment by running:
deactivate
  • Your project folder, database, and OAuth credentials will remain ready for your next session.

Delete - I don't want to use this again

Remove all project resources and OAuth credentials to start completely fresh.

  • Stop the FastAPI dev server if running by pressing Ctrl+C.
  • Deactivate your virtual environment by running:
deactivate
  • Delete your project folder from the Desktop by running:

macOS

cd ~/Desktop
rm -rf resume-portal

Windows

cd ~\Desktop
rmdir /s /q resume-portal
  • This removes all Python files, the virtual environment, and the resume_portal.db SQLite database.
  • Delete your Google Cloud OAuth credentials. Go to the Google Cloud Console at console.cloud.google.com.
  • Open the Credentials page from the left sidebar.
  • Find your OAuth 2.0 Client ID, click the delete icon next to it, and confirm the deletion.
  • Delete your Microsoft Entra app registration. Go to the Microsoft Entra admin center at entra.microsoft.com.
  • Navigate to Identity > Applications > App registrations.
  • Select your app, then click Delete on the Overview page and confirm.

Nice Work!

Nice Work!

You just built a production-grade Resume Portal API with real authentication, database-backed storage, and an admin approval workflow. That is a serious backend system.

You've learned how to:

  • Implement Single Sign-On authentication with Google and Microsoft using OAuth2/OpenID Connect, issuing JWT tokens to persist login sessions.
  • Design and query a SQLite database using SQLModel to store users, profiles, and role data with type-safe Python models.
  • Enforce role-based access control across three permission levels and build a complete profile submission and approval workflow with protected FastAPI endpoints.
  • Secret Mission: Added a reject-with-reason feature and a super_admin-only endpoint to promote users to the admin role.

Ready to quiz yourself?