What this module covers: Authentication answers "who are you?" Authorization answers "what are you allowed to do?" These are two distinct concerns that most tutorials conflate. This module covers the complete production authentication stack: bcrypt for password hashing, JWT for stateless tokens, refresh token rotation for long-lived sessions, Express middleware for protecting routes, and role-based access control. By the end you will have a working auth system you can drop into any Node.js API.
Authentication vs Authorization
Before writing any code, get the terminology straight — confusing these two causes real security bugs:
Authentication — verifying identity. "Is this really Jatin?" Handled by login, tokens, sessions.
Authorization — verifying permission. "Is Jatin allowed to delete this post?" Handled by role checks, ownership checks, policies.
A user can be fully authenticated (we know who they are) and still be unauthorized (they don't have permission for this specific action). Both checks are needed, and they run in that order.
Passwords: Never Store Plaintext
If your database is ever breached, plaintext passwords give attackers instant access to every account — and to every other site where users reused that password. Always hash passwords with a slow, purpose-built algorithm.
bcrypt is the standard. It uses a configurable "cost factor" (work factor) that controls how long hashing takes. The higher the cost, the more computation an attacker needs to brute-force the hash.
bash
javascript
Cost factor guidelines:
Environment
Recommended cost
Approx. time per hash
Development
10
~65ms
Production
12
~250ms
High-security
14
~1 second
At cost 12, a user waits ~250ms on login — imperceptible. An attacker trying to brute-force a leaked database hash faces 250ms per attempt. At scale, that's the difference between cracking a password in hours vs years.
Timing-safe comparison:bcrypt.compare is timing-safe — it takes the same amount of time regardless of whether the password is correct or not. This prevents timing attacks that infer the correct password by measuring response time differences.
JSON Web Tokens (JWT)
After a user authenticates, you need a way to identify them on subsequent requests without asking for their password again. JWT is the most common stateless solution.
bash
JWT Structure
A JWT is a Base64URL-encoded string with three parts separated by dots: header.payload.signature (Base64URL, not standard Base64, because standard Base64's +, /, and = characters aren't safe to put in a URL or header value unescaped)
Signature — HMAC of header+payload using your secret key
The payload is Base64-encoded, not encrypted — anyone can decode and read it. Never put sensitive data (passwords, PII) in the payload. The signature guarantees it has not been tampered with.
Signing and verifying tokens
javascript
JWT Algorithm-Confusion Attacks
jwt.verify(token, ACCESS_SECRET) above works, but omitting the algorithms option is itself a known vulnerability class. jsonwebtoken will accept whatever algorithm the token's own header claims. If your codebase ever also handles RS256 tokens (signed with an RSA private key, verified with the corresponding public key) anywhere, an attacker can craft a token signed with HMAC using that public key as the secret — public keys are, by definition, public — and it will pass verification if the code trusts the header's algorithm. This is not theoretical; it is a real CVE-class bug that has hit multiple JWT libraries and frameworks over the years.
The fix costs one line — pin the algorithm explicitly instead of trusting the token to declare it:
javascript
Apply this to every jwt.verify call in the codebase, not just this one — it tells the library "only ever trust HS256 signatures," full stop, regardless of what the token header says.
bash
Generate secure secrets:
bash
Why Two Tokens?
Analogy: think of the access token as a hotel keycard that demagnetizes itself every 15 minutes, while the refresh token is the entry in the front desk's register that decides whether housekeeping will cut you a new card at all.
A single long-lived token is a security liability — if stolen, the attacker has access for days or weeks. The two-token pattern mitigates this:
Access token — short-lived (15 min). Sent with every API request. Verified entirely from the signature — no database lookup needed. If stolen, expires quickly.
Refresh token — long-lived (7 days). Stored securely. Used only to get a new access token when the old one expires. Can be invalidated by deleting it from the database.
text
The Complete Auth Flow
User registration
javascript
User login
javascript
Token refresh
javascript
Refresh token rotation: every time a refresh token is used, it is deleted and a new one is issued. If a stolen refresh token is used, the legitimate user's next refresh will fail (their token was also invalidated). This is how you detect token theft.
Production story: on a UPI-scale payments rail, refresh-token rotation once caught real theft. A partner bank's integration accidentally logged full refresh tokens in a shared observability tool, and within the hour a script replayed one. Both the attacker's and the legitimate client's next refresh raced for the same now-deleted token; the collision paged on-call before the customer noticed. The postmortem is why the team added a token-family kill switch — revoking every token descended from a compromised one — instead of deleting just the single reused token.
Logout
javascript
Password Reset and Email Verification
Two more flows round out a production auth system. Both follow the same shape: generate a single-use, expiring token, email it, and require it back before doing anything sensitive.
Password reset
javascript
Three things make this safe: the token is random and long enough to be unguessable, it is stored hashed (a database leak doesn't hand out working reset tokens), and it is deleted after exactly one use.
Email verification at registration
Same shape, lower stakes — you're confirming an inbox is reachable, not authorizing a sensitive account change:
javascript
Whether an unverified user can log in at all, or only gets a reduced set of permissions, is a product decision — but the token mechanics (random, hashed, expiring, single-use) are identical to password reset.
Account Lockout After Repeated Failed Logins
P-6 covers IP-based rate limiting on /auth/login — throttling requests from a single address. That catches a script hammering the endpoint from one IP, but does nothing against credential stuffing spread across a botnet, where each attacker IP only tries a handful of passwords. The complementary control is account-level lockout: track failed attempts per account, regardless of source IP, and lock the account after too many.
javascript
This needs failedLoginAttempts and lockedUntil columns on users. Keep the error message generic (423 Locked with no detail on the remaining wait time) so an attacker can't use it to enumerate which accounts are close to locking. Combine this with the IP-based rate limiter from P-6 — one stops a single source from brute-forcing many accounts, the other stops many sources from brute-forcing one account.
The Authentication Middleware
This middleware extracts the JWT from the Authorization header, verifies it, and attaches the decoded user to req.user.
javascript
Usage in routes:
javascript
Authorization: Role-Based Access Control
Authentication confirms identity. Authorization enforces what they can do. The most common approach is role-based access control (RBAC) — users have a role, roles have permissions.
javascript
javascript
Ownership checks
RBAC is not enough for "users can only edit their own posts". That requires an ownership check in the service:
javascript
javascript
Ownership logic lives in the service, not the route or middleware. It has access to the full business context.
Auth Routes
javascript
javascript
Reading req.cookies requires the cookie-parser middleware to be mounted once at the app level:
bash
javascript
(P-6 covers the full secure-cookie-flags checklist and CSRF implications of this setup in more depth.)
OAuth 2.0 Social Login
Everything so far assumes the user has a password with you. Social login — "Sign in with Google" — delegates identity verification to a provider that already knows the user, and hands your API back proof of who they are. It plugs into the exact JWT system already built: once we know who the user is, we issue our own accessToken/refreshToken pair exactly as register() and login() do above. Google (or GitHub) never sees our tokens, and we never see the user's Google password.
The Authorization-Code Flow, at a High Level
text
Two details matter more than the rest of the diagram:
Redirect URI — the URL Google sends the browser back to (/auth/google/callback) must be registered exactly (scheme, host, path) in the Google Cloud Console for your OAuth client. Google refuses to redirect anywhere else. This is what stops an attacker from registering their own app and pointing your users' authorization codes at a server they control.
state parameter — a random value your server generates before redirecting to Google, and checks on the way back. Without it, an attacker can start their own OAuth flow, capture the resulting code, and trick a victim's browser into hitting your callback URL with the attacker's code — logging the victim into the attacker's account (a CSRF-shaped login attack, sometimes called "login CSRF"). Verifying state matches what you issued closes that hole.
Manual Implementation with fetch
This module has consistently shown the raw mechanics before reaching for a library (bcrypt calls directly, jsonwebtoken directly), so here is OAuth the same way — no framework magic, five explicit steps:
javascript
javascript
javascript
This assumes a small oauthAccounts table with a (provider, providerAccountId) unique constraint and a userId foreign key into the same users table used everywhere else in this module — one user can have zero, one, or several linked providers. It also requires passwordHash to be nullable on users: a Google-only account has no password until they set one. That means the password-based login() from earlier must guard against passwordHash being null before calling bcrypt.compare (which throws on a non-string hash), returning the same generic "Invalid email or password" error rather than revealing that the account only supports Google sign-in.
javascript
Or: passport + passport-google-oauth20
If hand-rolling the exchange is more than you want to maintain, passport wraps the same five steps behind a strategy interface:
bash
javascript
javascript
One catch with session: false: passport-oauth2's default state handling stores the value in req.session, which does not exist in a fully stateless API like this one. Either mount a minimal express-session just for the OAuth handshake (it does not need to touch the rest of the app, which stays JWT-only), or supply a custom store option that persists state the same way the manual version above does with a signed cookie. This is the one place a "stateless" API briefly needs state.
GitHub OAuth follows the identical shape — different authorize/token/profile URLs and a passport-github2 strategy — so the find-or-create-and-link logic above does not change per provider; only the endpoints and profile field names do.
Session-Based Auth vs Stateless JWT
You will encounter both in production. Choosing correctly matters:
JWT (stateless)
Sessions (stateful)
Server state
None — self-contained token
Session store (Redis/DB)
Revocation
Hard — token valid until expiry
Instant — delete session
Horizontal scaling
Trivial — any server can verify
Needs shared session store
Token theft response
Wait for expiry
Delete session immediately
Complexity
Refresh token rotation required
Simpler — one session ID
Use JWT when: you have multiple services, horizontal scaling, or mobile clients. The statelessness simplifies architecture.
Use sessions when: you need instant revocation (e.g. "log out all devices"), you have a monolith, or your team is more familiar with sessions. Express + express-session + Redis is the standard stack.
For most modern APIs (especially mobile or multi-service), JWT with refresh token rotation is the right choice.
Security Checklist
Before shipping auth:
Passwords hashed with bcrypt, cost factor ≥ 12
JWT secrets are long (≥ 32 bytes), random, and different for access vs refresh
Access tokens expire in ≤ 15 minutes
Refresh tokens are stored in the database and rotated on use
Login returns the same error for "wrong email" and "wrong password" (prevents user enumeration)
Rate limiting on /auth/login and /auth/register (covered in P-6)
HTTPS enforced in production — tokens in plaintext over HTTP are useless
Refresh tokens sent in HttpOnly cookies rather than response body (prevents XSS theft)
Authorization header checked with startsWith('Bearer '), not split/regex
Summary
Passwords: always bcrypt with cost ≥ 12. Never MD5, SHA1, or plaintext. bcrypt.compare is timing-safe.
JWT: header.payload.signature. Payload is readable — never put secrets in it. Signature prevents tampering.
Two-token pattern: short-lived access tokens (15 min, stateless), long-lived refresh tokens (7 days, stored in DB). Rotation on use detects theft.
authenticate middleware extracts the Bearer token, verifies it, attaches req.user. Run it on every protected route.
Authorization: authorize('admin') middleware for role checks. Ownership checks belong in the service layer.
Same error message for "user not found" and "wrong password" — never reveal which one failed.
JWT vs sessions: JWT for multi-service/mobile, sessions for instant revocation in monoliths.
Next: TypeScript in Node.js — adding type safety to everything you have built so far.
Knowledge Check
When implementing refresh token rotation, what happens when a user attempts to use a refresh token that has already been used?
In an Express application, where should the "Ownership check" logic (e.g., verifying a user can only edit their own post) typically reside?
Which scenario strongly favors using stateful Session-based Authentication over stateless JWTs?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Client API Server
│ │
│──── POST /auth/login ──────►│
│◄─── { accessToken } + Set-Cookie: refreshToken (HttpOnly) ─│
│ │
│──── GET /posts (Authorization: Bearer <accessToken>) ──►│
│◄─── 200 posts ─────────────│
│ │
│ (15 minutes later, accessToken expires)
│ │
│──── POST /auth/refresh (refreshToken cookie sent automatically) ──►│
│◄─── { newAccessToken } + Set-Cookie: refreshToken (rotated) ────│
// src/services/auth.service.jsimportbcryptfrom'bcrypt';importjwtfrom'jsonwebtoken';import{ signAccessToken, signRefreshToken }from'../utils/jwt.js';import*as usersRepofrom'../repositories/users.repository.js';import*as tokensRepofrom'../repositories/tokens.repository.js';import{AppError}from'../errors/AppError.js';constSALT_ROUNDS=12;exportasyncfunctionregister({ name, email, password }){// Check for existing userconst existing =await usersRepo.findByEmail(email);if(existing)thrownewAppError('Email already registered',409);// Hash the passwordconst passwordHash =await bcrypt.hash(password,SALT_ROUNDS);// Create the userconst user =await usersRepo.create({ name, email, passwordHash });// Issue tokensconst accessToken =signAccessToken(user.id, user.role);const refreshToken =signRefreshToken(user.id);// Store refresh token (so we can invalidate it later)await tokensRepo.create({userId: user.id,token: refreshToken });// refreshToken is returned to the controller only — it sets the HttpOnly// cookie and must NOT forward it into the JSON response body (see Security Checklist)return{user:{id: user.id,name: user.name,email: user.email}, accessToken, refreshToken };}
exportasyncfunctionlogin({ email, password }){// Find the userconst user =await usersRepo.findByEmail(email);if(!user)thrownewAppError('Invalid email or password',401);// Note: same error for "user not found" and "wrong password"// Never tell attackers which one it is// Verify password — guard against passwordHash being null (an OAuth-only// account has no password yet), since bcrypt.compare throws on a non-string// hash rather than just returning falseconst isValid = user.passwordHash&&await bcrypt.compare(password, user.passwordHash);if(!isValid)thrownewAppError('Invalid email or password',401);// Issue new tokensconst accessToken =signAccessToken(user.id, user.role);const refreshToken =signRefreshToken(user.id);await tokensRepo.create({userId: user.id,token: refreshToken });// Same rule as register(): refreshToken goes to the controller for cookie-setting,// never directly into the JSON body sent to the clientreturn{user:{id: user.id,name: user.name,email: user.email}, accessToken, refreshToken };}
exportasyncfunctionrefresh(incomingRefreshToken){// Verify the refresh token is valid and not expiredlet payload;try{ payload = jwt.verify(incomingRefreshToken, process.env.JWT_REFRESH_SECRET);}catch{thrownewAppError('Invalid or expired refresh token',401);}// Check it exists in the database (not been revoked)const stored =await tokensRepo.findByToken(incomingRefreshToken);if(!stored)thrownewAppError('Refresh token revoked',401);// Rotation: delete old token, issue new pairawait tokensRepo.deleteByToken(incomingRefreshToken);const user =await usersRepo.findById(payload.sub);const newAccessToken =signAccessToken(user.id, user.role);const newRefreshToken =signRefreshToken(user.id);await tokensRepo.create({userId: user.id,token: newRefreshToken });return{accessToken: newAccessToken,refreshToken: newRefreshToken };}
exportasyncfunctionlogout(refreshToken){await tokensRepo.deleteByToken(refreshToken);// Access tokens cannot be invalidated (they're stateless)// They expire on their own after 15 minutes}
// src/services/auth.service.jsimport{ randomBytes, createHash }from'crypto';constRESET_TOKEN_TTL_MS=60*60*1000;// 1 hourexportasyncfunctionrequestPasswordReset(email){const user =await usersRepo.findByEmail(email);// Respond the same way whether or not the user exists — don't leak// which emails are registered (same principle as the login error above)if(!user)return;const rawToken =randomBytes(32).toString('hex');const tokenHash =createHash('sha256').update(rawToken).digest('hex');// store the hash, never the raw tokenawait passwordResetTokensRepo.create({userId: user.id, tokenHash,expiresAt:newDate(Date.now()+RESET_TOKEN_TTL_MS),});awaitsendPasswordResetEmail(user.email, rawToken);// raw token only ever leaves the server via email}exportasyncfunctionresetPassword(rawToken, newPassword){const tokenHash =createHash('sha256').update(rawToken).digest('hex');const stored =await passwordResetTokensRepo.findByHash(tokenHash);if(!stored || stored.expiresAt<newDate()){thrownewAppError('Invalid or expired reset token',400);}const passwordHash =await bcrypt.hash(newPassword,SALT_ROUNDS);await usersRepo.updatePassword(stored.userId, passwordHash);// Single-use: delete the token immediately, and invalidate every existing// refresh token for this user so a stolen password can't ride an old sessionawait passwordResetTokensRepo.deleteByHash(tokenHash);await tokensRepo.deleteAllForUser(stored.userId);}
exportasyncfunctionregister({ name, email, password }){const passwordHash =await bcrypt.hash(password,SALT_ROUNDS);const user =await usersRepo.create({ name, email, passwordHash,emailVerified:false});const verifyToken =randomBytes(32).toString('hex');await emailVerificationTokensRepo.create({userId: user.id,tokenHash:createHash('sha256').update(verifyToken).digest('hex'),expiresAt:newDate(Date.now()+24*60*60*1000),// 24h});awaitsendVerificationEmail(user.email, verifyToken);// The user can usually still log in immediately — most APIs gate specific// actions (posting, payouts) on emailVerified rather than blocking login entirelyreturn user;}
// Protected route — must be logged inrouter.get('/profile', authenticate, usersController.getProfile);// Public route — no authenticate middlewarerouter.post('/register', authController.register);router.post('/login', authController.login);
// src/middleware/authorize.js// Middleware factory: authorize(allowedRoles)exportfunctionauthorize(...allowedRoles){return(req, res, next)=>{// authenticate must run before authorizeif(!req.user){returnnext(newUnauthorizedError());}if(!allowedRoles.includes(req.user.role)){returnnext(newForbiddenError('Insufficient permissions'));}next();};}
// src/routes/admin.routes.jsimport{ authenticate }from'../middleware/auth.js';import{ authorize }from'../middleware/authorize.js';// Only adminsrouter.get('/stats', authenticate,authorize('admin'), adminController.getStats);// Admins and moderatorsrouter.delete('/posts/:id', authenticate,authorize('admin','moderator'), postsController.forceDelete);// Any authenticated userrouter.get('/feed', authenticate, postsController.getFeed);
// src/services/posts.service.jsexportasyncfunctionupdatePost(postId, updates, requestingUserId){const post =await postsRepo.findById(postId);if(!post)thrownewNotFoundError('Post');// Ownership check — user can only edit their own postsif(post.authorId!== requestingUserId){thrownewForbiddenError('You can only edit your own posts');}return postsRepo.update(postId, updates);}
// src/controllers/posts.controller.jsexportasyncfunctionupdatePost(req, res, next){try{const post =await postsService.updatePost(parseInt(req.params.id), req.body, req.user.id,// from authenticate middleware); res.json(post);}catch(err){next(err);}}
// src/controllers/auth.controller.jsimport*as authServicefrom'../services/auth.service.js';// Sets the refresh token as an HttpOnly cookie instead of returning it in the// JSON body — client-side JS can never read it, which closes off the XSS// theft path called out in the Security Checklist below.functionsetRefreshTokenCookie(res, token){ res.cookie('refreshToken', token,{httpOnly:true,// JS cannot read this cookiesecure: process.env.NODE_ENV==='production',// HTTPS only in productionsameSite:'strict',// not sent on cross-site requestsmaxAge:7*24*60*60*1000,// 7 days — matches refresh token expirypath:'/auth',// only sent to /auth/* routes});}exportasyncfunctionregister(req, res, next){try{const result =await authService.register(req.body);setRefreshTokenCookie(res, result.refreshToken); res.status(201).json({user: result.user,accessToken: result.accessToken});}catch(err){next(err);}}exportasyncfunctionlogin(req, res, next){try{const result =await authService.login(req.body);setRefreshTokenCookie(res, result.refreshToken); res.json({user: result.user,accessToken: result.accessToken});}catch(err){next(err);}}exportasyncfunctionrefresh(req, res, next){try{const incomingRefreshToken = req.cookies?.refreshToken;if(!incomingRefreshToken)return res.status(400).json({error:'refreshToken cookie required'});const tokens =await authService.refresh(incomingRefreshToken);setRefreshTokenCookie(res, tokens.refreshToken); res.json({accessToken: tokens.accessToken});}catch(err){next(err);}}exportasyncfunctionlogout(req, res, next){try{const refreshToken = req.cookies?.refreshToken;if(refreshToken)await authService.logout(refreshToken); res.clearCookie('refreshToken',{path:'/auth'}); res.status(204).send();}catch(err){next(err);}}
// src/controllers/oauth.controller.jsimport{ randomBytes }from'crypto';import{GOOGLE_AUTH_URL,GOOGLE_TOKEN_URL,GOOGLE_USERINFO_URL}from'../config/oauth.js';import{ signAccessToken, signRefreshToken }from'../utils/jwt.js';import*as usersRepofrom'../repositories/users.repository.js';import*as oauthAccountsRepofrom'../repositories/oauthAccounts.repository.js';import*as tokensRepofrom'../repositories/tokens.repository.js';import{AppError}from'../errors/AppError.js';// Must exactly match the URI registered in Google Cloud ConsoleconstREDIRECT_URI=`${process.env.API_BASE_URL}/auth/google/callback`;// Step 1: send the browser to GoogleexportfunctiongoogleLogin(req, res){const state =randomBytes(16).toString('hex');// Store state server-side via a short-lived, signed cookie so we can verify// it on the way back — this is the CSRF protection described above res.cookie('oauth_state', state,{httpOnly:true,maxAge:5*60*1000,sameSite:'lax'});const params =newURLSearchParams({client_id: process.env.GOOGLE_CLIENT_ID,redirect_uri:REDIRECT_URI,response_type:'code',scope:'openid email profile', state,}); res.redirect(`${GOOGLE_AUTH_URL}?${params}`);}// Step 2–5: Google redirects back here with ?code=...&state=...exportasyncfunctiongoogleCallback(req, res, next){try{const{ code, state }= req.query;// Reject if state doesn't match what we set — stops login CSRFif(!state || state !== req.cookies?.oauth_state){return res.status(400).json({error:'Invalid OAuth state'});} res.clearCookie('oauth_state');// Step 3: exchange the one-time code for Google's tokens (server-to-server)const tokenRes =awaitfetch(GOOGLE_TOKEN_URL,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:newURLSearchParams({ code,client_id: process.env.GOOGLE_CLIENT_ID,client_secret: process.env.GOOGLE_CLIENT_SECRET,redirect_uri:REDIRECT_URI,grant_type:'authorization_code',}),});if(!tokenRes.ok)thrownewAppError('Google token exchange failed',502);const{access_token: googleAccessToken }=await tokenRes.json();// Step 4: fetch the user's Google profile with Google's access tokenconst profileRes =awaitfetch(GOOGLE_USERINFO_URL,{headers:{Authorization:`Bearer ${googleAccessToken}`},});const profile =await profileRes.json();// { sub, email, name, ... }// Step 5: find-or-create in the SAME `users` table the password flow uses// above, then link the Google identity to itlet link =await oauthAccountsRepo.findByProvider('google', profile.sub);let user;if(link){ user =await usersRepo.findById(link.userId);}else{// No linked Google identity yet — does an account with this email// already exist? If so, link Google to it instead of creating a duplicate. user =await usersRepo.findByEmail(profile.email);if(!user){// Brand-new user, no password — they can only sign in via Google// until they set one (see Password Reset above for that mechanism) user =await usersRepo.create({name: profile.name,email: profile.email,passwordHash:null});}await oauthAccountsRepo.create({userId: user.id,provider:'google',providerAccountId: profile.sub});}// Issue OUR OWN JWTs — Google's tokens never leave this functionconst accessToken =signAccessToken(user.id, user.role);const refreshToken =signRefreshToken(user.id);await tokensRepo.create({userId: user.id,token: refreshToken }); res.cookie('refreshToken', refreshToken,{httpOnly:true,secure: process.env.NODE_ENV==='production',sameSite:'strict',maxAge:7*24*60*60*1000,path:'/auth',});// same flags as setRefreshTokenCookie() in auth.controller.js — extract to a shared helper if duplicated// Passing the access token in a redirect query string is simple but leaks// into browser history and referrer headers. A short-lived one-time code// that the frontend immediately exchanges via POST, or a URL fragment// (#accessToken=...) instead of a query string, are safer in production. res.redirect(`${process.env.FRONTEND_URL}/oauth/callback?accessToken=${accessToken}`);}catch(err){next(err);}}
// src/config/passport.jsimportpassportfrom'passport';import{StrategyasGoogleStrategy}from'passport-google-oauth20';import*as usersRepofrom'../repositories/users.repository.js';import*as oauthAccountsRepofrom'../repositories/oauthAccounts.repository.js';passport.use(newGoogleStrategy({clientID: process.env.GOOGLE_CLIENT_ID,clientSecret: process.env.GOOGLE_CLIENT_SECRET,callbackURL:'/auth/google/callback',},async(_accessToken, _refreshToken, profile, done)=>{try{// Same find-or-create-and-link logic as googleCallback abovelet link =await oauthAccountsRepo.findByProvider('google', profile.id);let user = link
?await usersRepo.findById(link.userId):await usersRepo.findByEmail(profile.emails?.[0]?.value);if(!user){ user =await usersRepo.create({name: profile.displayName,email: profile.emails?.[0]?.value,passwordHash:null,});}if(!link){await oauthAccountsRepo.create({userId: user.id,provider:'google',providerAccountId: profile.id});}done(null, user);}catch(err){done(err);}},));
// src/routes/auth.routes.jsrouter.get('/google', passport.authenticate('google',{scope:['profile','email'],session:false}));router.get('/google/callback', passport.authenticate('google',{session:false,failureRedirect:'/login?error=oauth_failed'}),(req, res)=>{// req.user is set by the strategy above — issue our own JWTs exactly as in googleCallbackconst accessToken =signAccessToken(req.user.id, req.user.role);const refreshToken =signRefreshToken(req.user.id); tokensRepo.create({userId: req.user.id,token: refreshToken });setRefreshTokenCookie(res, refreshToken); res.redirect(`${process.env.FRONTEND_URL}/oauth/callback?accessToken=${accessToken}`);},);