Reactive Accelerator
Tips and Tricks
Manage Refresh Token Rotation with NextAuth and Reddis (Credentials and oAuth)

NextJS + NestJS + Redis + NextAuth - সম্পূর্ণ অথেন্টিকেশন সিস্টেমের সাথে কিভাবে রিফ্রেশ টোকেন রোটেশন ম্যানেজ করতে হয়।

এই গাইডে আমরা একটি কমপ্লিট ফুলস্ট্যাক অথেন্টিকেশন সিস্টেম বানাবো যেখানেঃ

  • NextJS ফ্রন্টএন্ড থাকবে
  • NestJS ব্যাকএন্ড থাকবে
  • NextAuth দিয়ে মাল্টিপল প্রভাইডার (Credentials + OAuth) সাপোর্ট থাকবে
  • Redis দিয়ে টোকেন স্টোরেজ এবং টোকেন রোটেশন সিস্টেম থাকবে এবং
  • অ্যাক্সেস এবং রিফ্রেশ টোকেন পুরোপুরি হ্যান্ডেল করা হবে

এতে আমাদের যেসব টুলসের প্রয়োজন পরবেঃ

  • Node.js (16+)
  • Upstash Redis একাঊন্ট
  • Prisma
  • Mongodb (Prisma এর সাথে ডাটাবেস হিসেবে ব্যাবহার করার জন্য)

স্টেপ-০১ঃ প্রয়োজনীয় প্যাকেজ ইনস্টল

    npm install next-auth@latest @upstash/redis jwt-decode

স্টেপ-০২ঃ  Nest.js ব্যাকএন্ডের জন্য Prisma মডেল সেট-আপ করা।

 
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider     = "mongodb"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}
 
model User {
  id           String       @id @default(auto()) @map("_id") @db.ObjectId
  email        String       @unique
  name         String?      @default("")
  phone        String?      @default("")
  image        Json?        @default("{}")
  password     String?      // Made optional for OAuth users
  refreshToken String?
  isVerified   Boolean      @default(false)
  createdAt    DateTime     @default(now())
  updatedAt    DateTime     @updatedAt
  
  // OAuth related fields
  emailVerified DateTime?
 
  // Relations
  accounts     Account[]    // Add relation to accounts
}
 
// New model for OAuth accounts
model Account {
  id                String  @id @default(auto()) @map("_id") @db.ObjectId
  userId            String  @db.ObjectId
  provider          String  // 'google', 'twitter', 'apple', etc.
  providerAccountId String  // ID from the provider
  type              String  @default("oauth")
  
  // OAuth tokens
  access_token      String?
  refresh_token     String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  
  // Relation to user
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)
 
  // Ensure provider + providerAccountId is unique
  @@unique([provider, providerAccountId])
}
 

এখানে আমরা একটি User এন্ড Acounts নামে দুইটা মডেল বানাচ্ছি সেখানে ইউজার যখন Credentials দিয়ে সাইন-ইন করবে তখন সেই ডাটা গুলো User টেবিলে সেভ হবে এবং ইউজার যখন OAuth Provider দিয়ে সাইন-ইন করবে তখন সেই প্রভাইডার এবং একাঊণ্ট এর ইনফরমেশন গুলো Acounts টেবিলে সেভ হবে। এবং দুইটা টেবিলে রিলেশন ক্রিয়েট করা হয়েছে, যাতে আমরা যেকোন সময় রিলেশনাল ডেটাগুলো এক্সেস করেত পারি।

স্টেপ-০৩ঃ অথেনটিকেশনের জন্য ব্যাকএন্ডের AuthController AuthService সেটআপ করা

এখন আমরা যাতে ইউজারকে সাইন-ইন এন্ড সাইন-আপ করাতে পারি, এবং টোকেন মেনেজ করতে পারি তার জন্য

AuthController AuthService সেটআপ করাবো।

auth/authController.ts
         import {
         Body,
         Controller,
         HttpException,
         HttpStatus,
         Post,
         Res,
         } from '@nestjs/common';
         import { Response } from 'express';
         import { AuthService } from './auth.service';
         import { refreshTokenDto, signInDto, signUpDto } from './dto';
 
         import { SendOtpDto } from './dto/send-otp.dto';
         import { VerifyOtpDto } from './dto/verify-otp.dto';
 
         class SignInResponse {
         id: string;
         name: string | null;
         email: string;
         accessToken: string;
         refreshToken: string;
         }
 
         class RefreshTokenResponse {
         access_token: string;
         refresh_token: string;
         }
 
         @Controller('api/auth')
         export class AuthController {
         constructor(private authService: AuthService) {}
 
         // Sign Up with Credentials
         @Post('signup')
         async signUp(@Body() dto: signUpDto) {
             return this.authService.signUpWithCredentials(dto);
         }
 
         // Sign In with Credentials
         @Post('signin')
         async signIn(
             @Body() dto: signInDto,
             @Res({ passthrough: true }) res: Response,
         ): Promise<any> {
             const response = await this.authService.signInWithCredentials(dto);
             res.cookie('refresh_token', response.refreshToken, {
             httpOnly: true,
             secure: process.env.NODE_ENV !== 'development',
             sameSite: 'none',
             maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
             });
             return response;
         }
 
         // New endpoint for OAuth users
         @Post('oauth')
         async handleOAuthLogin(
             @Body() oauthData: any,
             @Res({ passthrough: true }) res: Response,
         ): Promise<any> {
             try {
             const response = await this.authService.handleOAuthLogin(oauthData);
             res.cookie('refresh_token', response.refreshToken, {
                 httpOnly: true,
                 secure: process.env.NODE_ENV !== 'development',
                 sameSite: 'none',
                 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
             });
             return response;
             } catch (error) {
             throw new HttpException(
                 error.message || 'OAuth login failed',
                 HttpStatus.UNAUTHORIZED,
             );
             }
         }
 
         // Refresh Token
         @Post('refresh-token')
         async getRefreshToken(
             @Body() dto: refreshTokenDto,
             @Res({ passthrough: true }) res: Response,
         ): Promise<RefreshTokenResponse> {
             const new_tokens = await this.authService.refreshToken(
             dto.refresh_token,
             dto.provider,
             );
             res.cookie('refresh_token', new_tokens.refresh_token, {
             httpOnly: true,
             secure: process.env.NODE_ENV !== 'development',
             sameSite: 'none',
             maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
             });
             return new_tokens;
         }
         }
 

স্টেপ-০৪ঃ ফ্রন্টএন্ডে এনভাইরনমেন্ট ভ্যেরিয়েবল সেট করা

# NextAuth
AUTH_SECRET=your_auth_secret
NEXTAUTH_URL=http://localhost:3000
 
# API URL
NEXT_PUBLIC_API_BASE_URL=http://localhost:3333
 
# OAuth Providers
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
 
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
 
TWITTER_CLIENT_ID=your_twitter_client_id
TWITTER_CLIENT_SECRET=your_twitter_client_secret
 
APPLE_ID=your_apple_id
APPLE_SECRET=your_apple_secret
 
# Redis
UPSTASH_REDIS_URL=your_redis_url
UPSTASH_REDIS_TOKEN=your_redis_token

স্টেপ-০৫ঃ ফ্রন্টএন্ডে auth.config.js ফাইল সেটআপ করা

    // frontend/auth.config.js
    const authConfig = {
        session: {
            strategy: "jwt",
        },
        providers: [],
    };
    export default authConfig;

স্টেপ-০৬ঃ ফ্রন্টএন্ডে auth.js ফাইল সেটআপ করা

import { Redis } from "@upstash/redis";
import { jwtDecode } from "jwt-decode";
import NextAuth from "next-auth";
import AppleProvider from "next-auth/providers/apple";
import CredentialsProvider from "next-auth/providers/credentials";
import GithubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import TwitterProvider from "next-auth/providers/twitter";
import authConfig from "./auth.config";
 
// Initialize Redis client
const redis = new Redis({
    url: process.env.UPSTASH_REDIS_URL,
    token: process.env.UPSTASH_REDIS_TOKEN,
});
 
/**
 * Fetches token from Redis storage
 */
async function getTokenFromRedis(userId, provider) {
    try {
        const key = `user:${userId}:${provider}:tokens`;
        const tokens = await redis.get(key);
        return tokens || null;
    } catch (error) {
        console.error("❌ [REDIS GET ERROR]:", error);
        return null;
    }
}
 
/**
 * Stores token in Redis storage
 */
async function storeTokenInRedis(userId, provider, tokens) {
    try {
        const key = `user:${userId}:${provider}:tokens`;
        // Set tokens with expiry of 7 days
        await redis.set(key, tokens, { ex: 60 * 60 * 24 * 7 });
        console.log(`💾 [REDIS STORE] Tokens stored for user ${userId}`);
    } catch (error) {
        console.error("❌ [REDIS STORE ERROR]:", error);
    }
}
 
/**
 * Removes token from Redis storage
 */
async function removeTokenFromRedis(userId, provider) {
    try {
        const key = `user:${userId}:${provider}:tokens`;
        await redis.del(key);
        console.log(`🗑️ [REDIS DELETE] Tokens removed for user ${userId}`);
    } catch (error) {
        console.error("❌ [REDIS DELETE ERROR]:", error);
    }
}
 
/**
 * Refreshes the access token when it's expired
 */
async function refreshAccessToken(token) {
    console.log("🔁 [REFRESH TOKEN] Trying to refresh access token...");
 
    // Check if we have fresher tokens in Redis
    if (token.id && token.provider) {
        const storedTokens = await getTokenFromRedis(token.id, token.provider);
        if (
            storedTokens &&
            storedTokens.accessTokenExpires > token.accessTokenExpires
        ) {
            console.log("🔄 [REFRESH TOKEN] Using fresher token from Redis");
            return {
                ...token,
                ...storedTokens,
            };
        }
    }
 
    // Use the refresh token from the token object
    const refreshTokenToUse = token.refreshToken;
    if (!refreshTokenToUse) {
        console.error("❌ [REFRESH TOKEN] No refresh token available");
        return {
            ...token,
            error: "RefreshTokenNotAvailable",
        };
    }
 
    try {
        const response = await fetch(
            `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/refresh-token`,
            {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
                body: JSON.stringify({
                    refresh_token: refreshTokenToUse,
                    provider: token.provider,
                }),
            }
        );
 
        const refreshedTokens = await response.json();
 
        if (!response.ok) {
            console.error("❌ [REFRESH TOKEN FAILED]:", refreshedTokens);
            throw refreshedTokens;
        }
 
        const decodedToken = jwtDecode(refreshedTokens.access_token);
        const expiresAt = decodedToken.exp * 1000;
 
        console.log(
            "✅ [REFRESH TOKEN] New access token expires at:",
            new Date(expiresAt)
        );
 
        const updatedToken = {
            ...token,
            accessToken: refreshedTokens.access_token,
            refreshToken: refreshedTokens.refresh_token,
            accessTokenExpires: expiresAt,
        };
 
        // Store the new tokens in Redis
        if (token.id && token.provider) {
            await storeTokenInRedis(token.id, token.provider, {
                accessToken: refreshedTokens.access_token,
                refreshToken: refreshedTokens.refresh_token,
                accessTokenExpires: expiresAt,
            });
        }
 
        return updatedToken;
    } catch (error) {
        console.error("❌ [REFRESH TOKEN ERROR]:", error);
        return {
            ...token,
            error: "RefreshAccessTokenError",
        };
    }
}
 
export const { handlers, signIn, signOut, auth } = NextAuth({
    secret: process.env.AUTH_SECRET,
    ...authConfig,
    providers: [
        CredentialsProvider({
            id: "credentials",
            name: "Credentials",
            credentials: {
                email: {},
                password: {},
            },
            async authorize(credentials) {
                console.log(
                    "🔐 [AUTHORIZE] Login attempt with email:",
                    credentials?.email
                );
 
                if (!credentials?.email || !credentials?.password) {
                    console.log("❌ [AUTHORIZE] Missing email or password");
                    throw new Error("Invalid credentials");
                }
 
                return {
                    ...credentials,
                    provider: "credentials",
                };
            },
        }),
        GoogleProvider({
            clientId: process.env.GOOGLE_CLIENT_ID,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET,
            authorization: {
                params: {
                    prompt: "consent",
                    access_type: "offline",
                    response_type: "code",
                },
            },
        }),
        TwitterProvider({
            clientId: process.env.TWITTER_CLIENT_ID,
            clientSecret: process.env.TWITTER_CLIENT_SECRET,
            version: "2.0",
        }),
        AppleProvider({
            clientId: process.env.APPLE_ID,
            clientSecret: process.env.APPLE_SECRET,
        }),
        GithubProvider({
            clientId: process.env.GITHUB_CLIENT_ID,
            clientSecret: process.env.GITHUB_CLIENT_SECRET,
        }),
    ],
 
    callbacks: {
        async signIn({ user, account, profile }) {
            console.log(
                "👋 [SIGN IN] User signing in via:",
                account?.provider || "unknown"
            );
 
            // Skip for credentials provider - already handled in authorize callback
            if (account?.provider === "credentials") {
                return true;
            }
 
            try {
                // Process OAuth login through NestJS backend
                const res = await fetch(
                    `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/oauth`,
                    {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        body: JSON.stringify({
                            provider: account.provider,
                            providerAccountId: account.providerAccountId,
                            profile: {
                                name: user.name,
                                email: user.email,
                                image: user.image,
                            },
                            tokens: {
                                access_token: account.access_token,
                                refresh_token: account.refresh_token,
                                expires_at: account.expires_at,
                            },
                        }),
                    }
                );
 
                const response = await res.json();
 
                if (!res.ok) {
                    console.error(
                        "❌ [SIGN IN] OAuth user registration failed:",
                        response.message
                    );
                    return false;
                }
 
                // Update the user object with data from NestJS backend
                user.id = response.id;
                user.accessToken = response.accessToken;
                user.refreshToken = response.refreshToken;
                user.provider = account.provider;
 
                // Store the tokens in Redis right away
                const decoded = jwtDecode(response.accessToken);
                const expiresAt = decoded.exp * 1000;
 
                await storeTokenInRedis(response.id, account.provider, {
                    accessToken: response.accessToken,
                    refreshToken: response.refreshToken,
                    accessTokenExpires: expiresAt,
                });
 
                return true;
            } catch (error) {
                console.error("❌ [SIGN IN] OAuth processing error:", error);
                return false;
            }
        },
 
        async jwt({ token, user, account }) {
            console.log("🔑 [JWT CALLBACK] Started");
 
            // Initial sign-in
            if (user) {
                console.log("👤 [JWT CALLBACK] New sign-in");
 
                let accessTokenExpires;
 
                // For OAuth providers
                if (account && account.provider !== "credentials") {
                    // OAuth token expiry calculation
                    accessTokenExpires = account.expires_at
                        ? account.expires_at * 1000
                        : Date.now() + 3600 * 1000;
                }
                // For credentials provider
                else {
                    try {
                        const decoded = jwtDecode(user.accessToken);
                        accessTokenExpires = decoded.exp * 1000;
                    } catch (error) {
                        console.error("❌ [JWT DECODE ERROR]:", error);
                        accessTokenExpires = Date.now() + 3600 * 1000; // Default to 1 hour if decode fails
                    }
                }
 
                console.log(
                    "✅ [JWT CALLBACK] Token expires at:",
                    new Date(accessTokenExpires)
                );
 
                // Store tokens in Redis during sign-in
                if (user.id) {
                    await storeTokenInRedis(user.id, user.provider, {
                        accessToken: user.accessToken,
                        refreshToken: user.refreshToken,
                        accessTokenExpires: accessTokenExpires,
                    });
                }
 
                return {
                    ...token,
                    accessToken: user.accessToken,
                    refreshToken: user.refreshToken,
                    accessTokenExpires: accessTokenExpires,
                    id: user.id,
                    email: user.email,
                    name: user.name,
                    provider: user.provider,
                };
            }
 
            // For subsequent requests, check Redis for fresher tokens
            if (token.id && token.provider) {
                const storedTokens = await getTokenFromRedis(
                    token.id,
                    token.provider
                );
                if (
                    storedTokens &&
                    storedTokens.accessTokenExpires > token.accessTokenExpires
                ) {
                    console.log("🔄 [JWT] Using fresher token from Redis");
                    return {
                        ...token,
                        ...storedTokens,
                    };
                }
            }
 
            // Check if token is expired
            if (
                token.accessTokenExpires &&
                Date.now() >= token.accessTokenExpires - 30000
            ) {
                console.log(
                    "⚠️ [JWT CALLBACK] Token expired or close to expiry"
                );
                return await refreshAccessToken(token);
            }
 
            return token;
        },
 
        async session({ session, token }) {
            console.log("📦 [SESSION CALLBACK] Building session");
 
            // Transfer token data to session
            session.accessToken = token.accessToken;
            session.refreshToken = token.refreshToken;
            session.accessTokenExpires = token.accessTokenExpires;
            session.provider = token.provider;
            session.user = {
                id: token.id,
                name: token.name,
                email: token.email,
                provider: token.provider,
            };
 
            if (token?.error) {
                console.error(
                    "⚠️ [SESSION CALLBACK] Error in token:",
                    token.error
                );
                session.error = token.error;
            }
 
            return session;
        },
    },
 
    events: {
        async signIn(message) {
            console.log("🚪 [EVENT] User signed in:", message.user.email);
        },
        async signOut({ token }) {
            console.log("👋 [EVENT] User signed out");
            // Clear tokens from Redis on sign out
            if (token?.id && token?.provider) {
                await removeTokenFromRedis(token.id, token.provider);
            }
        },
    },
 
    jwt: {
        maxAge: 60 * 60 * 24 * 7, // 7 days
    },
    pages: {
        signIn: "/sign-in",
        error: "/auth/error",
    },
    debug: process.env.NODE_ENV === "development",
});
 

স্টেপ-০৭ঃ Next.js কনফিগারেশন আপডেট করা

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  env: {
    UPSTASH_REDIS_URL: process.env.UPSTASH_REDIS_URL,
    UPSTASH_REDIS_TOKEN: process.env.UPSTASH_REDIS_TOKEN,
  },
}
 
module.exports = nextConfig

স্টেপ-০৮ঃ সার্ভার স্টার্ট হওয়ার সাথে সাথে মিডলওয়্যারের মাধ্যমে Redis কানেকশন সেট করা ।

middleware.js
//  middleware.js
import { Redis } from "@upstash/redis";
export async function middleware() {
  try {
    const redis = new Redis({
      url: process.env.UPSTASH_REDIS_URL,
      token: process.env.UPSTASH_REDIS_TOKEN,
    });
 
    await redis.ping();
    console.log("✅ Redis connection successful");
  } catch (error) {
    console.error("❌ Redis connection failed:", error);
  }
}

এই সবগুলো স্টেপ ফলো করলে আমরা একটা পারফেক্টলি ওয়ার্কিং সলিউশন পেয়ে যাবো।