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);
}
}
এই সবগুলো স্টেপ ফলো করলে আমরা একটা পারফেক্টলি ওয়ার্কিং সলিউশন পেয়ে যাবো।