1. Modeling Roles & Permissions
Authentication (Week 6) proves identity; authorization decides what
that identity is allowed to do. The simplest workable model is a single
role enum on User — enough for most APIs, and easy to extend
into a full permissions table later if you ever need per-action granularity instead of
per-role:
enum Role {
USER
EDITOR
ADMIN
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
passwordHash String? // nullable now -- OAuth users (Section 3) never set a password
googleId String? @unique
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
}
USER can read and comment; EDITOR can create and publish
posts; ADMIN can do both plus manage other users' roles. The enum's
declaration order isn't meaningful to Prisma — role checks below compare against exact
named values, never against numeric position — but it's worth writing them low to high
privilege for humans reading the schema. Note passwordHash is now
nullable: an OAuth-only user (Section 3) authenticates through Google and never sets a
local password at all, so the column has to allow that case.
The JWT payload from Week 6 needs to carry the role too, since the authorization
middleware in the next section reads it straight off req.user without a
database round trip — update the login handler's jwt.sign call to include
it:
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: "1h" },
);
If an admin demotes a user mid-session, that user's existing token still carries their old role until it expires — Week 6's short expiresIn is exactly what bounds how long a stale role can stay valid. For anything more sensitive than a blog (banking, admin panels with destructive actions), re-checking the role against the database on high-stakes actions, rather than trusting the token alone, is the safer default.
2. An Authorization Middleware
requireRole is a small function that returns a middleware — a factory
pattern, not a middleware itself — so you can parameterize which roles are allowed per
route. It always runs after requireAuth, since it depends entirely on
req.user already being set:
import type { Request, Response, NextFunction } from "express";
type Role = "USER" | "EDITOR" | "ADMIN";
export function requireRole(...allowed: Role[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
// requireAuth should always run first -- this means the routes were wired up wrong
res.status(401).json({ error: "Authentication required" });
return;
}
if (!allowed.includes(req.user.role)) {
res.status(403).json({ error: "You don't have permission to perform this action" });
return;
}
next();
};
}
import { requireAuth } from "../middleware/requireAuth.js";
import { requireRole } from "../middleware/requireRole.js";
// Any authenticated user can read; only EDITOR or ADMIN can create.
postsRouter.get("/", async (req, res, next) => { /* ... */ });
postsRouter.post(
"/",
requireAuth,
requireRole("EDITOR", "ADMIN"),
async (req, res, next) => { /* ... */ },
);
// Only ADMIN can change another user's role.
usersRouter.patch(
"/:id/role",
requireAuth,
requireRole("ADMIN"),
async (req, res, next) => { /* ... */ },
);
Stacking requireAuth then requireRole(...) as two separate
middlewares — rather than one combined function — keeps each one doing exactly one
job: authentication answers "who," authorization answers "what are they allowed to
do." That separation is what lets you mix and match freely, like a route that's public
to any logged-in user versus one gated to a specific role, without duplicating the
token-verification logic in both.
401 Unauthorized means "I don't know who you are" — no credentials, or invalid ones. 403 Forbidden means "I know exactly who you are, and the answer is still no." Returning 403 for a role mismatch (as above) rather than 401 tells a legitimate but under-privileged client not to bother retrying with a fresh token; the problem isn't their authentication, it's their permissions.
3. OAuth with Passport.js
OAuth lets a user authenticate through a provider they already trust — Google, in this example — instead of creating a password with you at all. Passport.js is the standard Express middleware for this: a "strategy" plugin per provider handles the redirect dance, and your app only has to react to the result.
npm install passport passport-google-oauth20
npm install -D @types/passport @types/passport-google-oauth20
import passport from "passport";
import { Strategy as GoogleStrategy, type Profile } from "passport-google-oauth20";
import { prisma } from "./prisma.js";
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: "/auth/google/callback",
},
async (_accessToken: string, _refreshToken: string, profile: Profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) {
return done(new Error("Google account has no email"));
}
// find-or-create: see Section 4 for why we match on profile.id, not just email
let user = await prisma.user.findUnique({ where: { googleId: profile.id } });
if (!user) {
user = await prisma.user.create({
data: {
googleId: profile.id,
email,
name: profile.displayName,
// passwordHash stays null -- this user only ever logs in via Google
},
});
}
done(null, user);
} catch (err) {
done(err as Error);
}
},
),
);
export { passport };
import { passport } from "./lib/passport.js";
app.use(passport.initialize()); // no passport.session() -- we issue our own JWT, not a cookie session
app.get(
"/auth/google",
passport.authenticate("google", { scope: ["profile", "email"], session: false }),
);
Hitting GET /auth/google redirects the browser to Google's consent
screen; the scope array requests the specific pieces of profile data your
strategy's callback needs. session: false everywhere in this setup is
deliberate — Passport defaults to cookie-based sessions, but this app is already
stateless via JWTs from Week 6, and mixing both session cookies and bearer tokens in
one API adds complexity for no benefit here.
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET come from the Google Cloud Console and belong in your .env file, which should already be in .gitignore from Week 1's project setup. Anyone with the client secret can impersonate your app to Google's OAuth servers.
4. The Callback & Linking Accounts
After the user approves access on Google's consent screen, Google redirects the
browser back to your callbackURL with an authorization code. Passport
exchanges that code for the user's profile behind the scenes and hands it to the
strategy's callback from Section 3 — your route handler runs after all of that,
receiving the user Passport attached to req.user:
import jwt from "jsonwebtoken";
import type { User } from "@prisma/client";
app.get(
"/auth/google/callback",
passport.authenticate("google", { session: false, failureRedirect: "/login" }),
(req, res) => {
const user = req.user as User; // Passport's own req.user, distinct from Week 6's augmentation
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: "1h" },
);
// Hand the token to the frontend however your client expects it --
// a redirect with the token in a query param is common for a first pass.
res.redirect(`${process.env.FRONTEND_URL}/oauth-complete?token=${token}`);
},
);
This is the key idea to hold onto: Google authenticating the user is not the same
thing as your API authenticating them. The callback route still issues the
exact same kind of JWT the password-based login route from Week 6 issues, using the
exact same requireAuth middleware to verify it afterward. From the rest of
the app's point of view, a user who signed in with Google and a user who signed in with
a password are indistinguishable past this point — both arrive at every other route as
a normal, verified req.user.
Matching the incoming Google profile to a User row by
profile.id (Section 3), rather than by email alone, matters more than it
looks: emails can be unverified, reused across providers, or changed later, while
profile.id is a stable, provider-issued identifier that never changes for
a given Google account. If you want to let an existing password-based user
link a Google account rather than always creating a new one, do that lookup
deliberately — check for an existing user by email first, and if found, set that user's
googleId instead of creating a duplicate row:
let user = await prisma.user.findUnique({ where: { googleId: profile.id } });
if (!user) {
const existingByEmail = await prisma.user.findUnique({ where: { email } });
if (existingByEmail) {
// Same person, second login method -- link it instead of creating a duplicate account.
user = await prisma.user.update({
where: { id: existingByEmail.id },
data: { googleId: profile.id },
});
} else {
user = await prisma.user.create({
data: { googleId: profile.id, email, name: profile.displayName },
});
}
}
5. Hands-on Exercise
Add roles and Google OAuth login to the blog API
Layer authorization on top of Week 6's authentication, then add a second, passwordless way to arrive at the same authenticated req.user.
Requirements:
- Add the
Roleenum andgoogleIdfield toUserper Section 1, migrate, and update the login handler's JWT payload to includerole. - Implement
requireRoleper Section 2, and gatePOST /poststoEDITOR/ADMINand a newPATCH /users/:id/roletoADMINonly. - Register a Google OAuth app in the Google Cloud Console (redirect URI
http://localhost:3000/auth/google/callbackfor local dev), and wire up the strategy and routes from Sections 3–4. - Implement the find-or-link-or-create logic from Section 4's second snippet, and manually verify all three paths: a brand-new Google login, a returning Google user, and an existing password-based user logging in with Google for the first time (should link, not duplicate).
- Confirm a
USER-role account gets a 403 (not 401) fromPOST /posts, and that an unauthenticated request to the same route gets a 401.
For step 4's "existing password-based user" case, create a user via POST /auth/register first with a specific email, then run through the Google OAuth flow using a Google test account that shares that same email. Query the database afterward and confirm there's still exactly one row for that email, now with a non-null googleId.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is authorization written as a separate requireRole middleware layered after requireAuth, instead of one combined middleware that does both?
Why is authorization written as a separate requireRole middleware layered after requireAuth, instead of one combined middleware that does both?
Keeping them separate means each middleware has exactly one job — requireAuth verifies identity, requireRole checks permission — which lets routes compose them differently: some routes need only authentication (any logged-in user), others need authentication plus a specific role. Combining them into one function would force every route to duplicate token-verification logic even for routes that only care about role, or vice versa.
Q2
Why does requireRole return 403 for a role mismatch instead of 401, and why does that distinction matter to the client?
Why does requireRole return 403 for a role mismatch instead of 401, and why does that distinction matter to the client?
401 means the server doesn't know who's making the request — no token, or an invalid one — and the fix is to authenticate again. 403 means the server knows exactly who the user is and has decided they're not allowed to do this specific thing regardless. Returning 403 tells the client not to bother retrying with a "fresher" token, since the problem is the user's role, not their credentials.
Q3
Why does the OAuth callback route still call jwt.sign(...) even though Google has already authenticated the user?
Why does the OAuth callback route still call jwt.sign(...) even though Google has already authenticated the user?
Google authenticating the user only proves their identity to Google, for the duration of that one redirect flow — it says nothing to the rest of your API on subsequent requests. Issuing your own JWT at the end of the callback converts that one-time proof into the same kind of credential the password-based login route produces, so every other route in the app can keep using requireAuth without needing to know or care which method the user originally signed in with.
Q4
Why match an incoming Google profile to a User row by profile.id rather than by email alone?
Why match an incoming Google profile to a User row by profile.id rather than by email alone?
profile.id is a stable identifier Google issues once per account and never changes, while email addresses can be unverified, changed later, or in principle reused, making them a shakier key to permanently identify an account by. Matching on profile.id first, and only falling back to an email lookup when deliberately trying to link an existing password-based account (as in Section 4's second snippet), avoids silently merging two different people's accounts just because an email happened to collide.