Xây dựng server xác thực OAuth đơn giản bằng Node.js

Table of contents
- OAuth 2.0 là gì?
- Phân biệt xác thực và ủy quyền
- OAuth – Ủy quyền được ủy thác
- Tại sao cần OAuth?
- Các thành phần chính trong OAuth
- Các luồng hoạt động chính của OAuth 2.0
- So sánh OAuth 2.0 và OAuth 1.0
- Ví dụ đăng nhập bằng Google vào một trang web
- 🧠 Tóm lại
- 💡 Vậy lợi ích là gì?
- Xây dựng một server xác thực OAuth đơn giản bằng Node.js

OAuth 2.0 là gì?
OAuth 2.0 là một chuẩn ủy quyền (authorization) mở, được sử dụng rộng rãi, cho phép ứng dụng bên thứ ba truy cập một cách giới hạn vào tài nguyên từ một dịch vụ HTTP khác (như Google, Facebook, GitHub) thay mặt người dùng — sau khi người dùng cho phép.
Khi bạn vào một trang web và thấy các nút “Đăng nhập bằng Google/Facebook” – đó chính là OAuth đang hoạt động. Bạn không cần nhập mật khẩu vào trang đó, chỉ cần đồng ý cho phép truy cập là xong.
Phân biệt xác thực và ủy quyền
Trước khi đi sâu vào OAuth, cần hiểu rõ hai khái niệm:
Xác thực (Authentication): Xác minh bạn là ai (bạn có đúng là người dùng đó không).
Ủy quyền (Authorization): Quyết định bạn có quyền làm gì (truy cập tài nguyên nào).
Ví dụ trên eminelinsights:
Người đọc không cần đăng nhập vẫn đọc bài viết.
Nhưng để bình luận hoặc chỉnh sửa, bạn phải đăng ký – tức là cần xác thực và được ủy quyền.
OAuth – Ủy quyền được ủy thác
OAuth hoạt động dựa trên mô hình ủy quyền ủy thác (delegated access). Hãy hình dung như sau:
Chủ nhà giao chìa khóa cho môi giới để dẫn khách đi xem nhà. Khách chỉ được xem, không được ở lại – tương tự như việc ứng dụng bên thứ ba chỉ được quyền "nhìn thấy" một phần dữ liệu của bạn.
Valet parking: Chủ xe đưa "chìa khóa phụ" cho nhân viên trông xe – chỉ mở cửa và khởi động xe, không mở được cốp hay hộc đồ. OAuth cũng vậy: chỉ cấp quyền đúng phạm vi cần thiết (scope), không cấp toàn bộ quyền truy cập.
Tại sao cần OAuth?
OAuth giúp:
Tránh chia sẻ toàn bộ thông tin tài khoản (như user/password).
Giới hạn quyền truy cập của bên thứ ba (chỉ xem email, không gửi email).
Cho phép thu hồi quyền truy cập bất kỳ lúc nào.
Ví dụ:
Ứng dụng chỉnh sửa ảnh muốn lấy một bức ảnh từ Google Drive: chỉ cần quyền đọc đúng file ảnh đó.
Nếu bạn chia sẻ ảnh với bạn bè qua Gmail từ ứng dụng, ứng dụng chỉ cần truy cập danh bạ (không cần biết nội dung email).
Các thành phần chính trong OAuth
a. Các vai trò chính (Actors)
Vai trò | Mô tả |
Resource Owner | Người sở hữu dữ liệu (người dùng). |
Client | Ứng dụng bên thứ ba cần truy cập dữ liệu. |
Resource Server | API lưu trữ dữ liệu (Google Drive, Facebook...). |
Authorization Server | Cấp mã truy cập (Access Token). |
b. Scope và Consent
Scope: Là phạm vi quyền hạn ứng dụng yêu cầu (xem email, danh bạ...).
Consent: Người dùng đồng ý cấp quyền khi OAuth hiển thị màn hình xác nhận.
c. Token là gì?
Access Token: Là “vé vào cửa” dùng để truy cập dữ liệu. Có thời hạn, giống như vé xem phim.
Refresh Token: Là “phiếu giảm giá” dùng để đổi vé mới khi vé cũ hết hạn – không gia hạn vé, mà lấy vé mới.
Ví dụ về access token dạng JWT:
jsonCopyEdit{
"iss": "https://YOUR_DOMAIN/",
"sub": "auth0|123456",
"scope": "openid profile email",
...
}
Các luồng hoạt động chính của OAuth 2.0
1. Authorization Code Flow (Dùng cho server-side apps)
Quy trình:
Client chuyển hướng người dùng đến Authorization Server.
Người dùng đăng nhập → cấp quyền → nhận mã ủy quyền (authorization code).
Client dùng mã này để xin Access Token.
Access Token được dùng để truy cập API.
→ Bảo mật cao vì không lộ thông tin trong URL, token chỉ cấp sau khi xác minh.
2. Implicit Flow (Dành cho ứng dụng chạy hoàn toàn trên trình duyệt)
Không có bước trung gian (mã ủy quyền), token được trả ngay trong URL.
Dễ bị tấn công XSS/phishing vì không xác minh client.
Hiện nay đã bị khuyến cáo không dùng.
3. Resource Owner Password Credentials Flow (Người dùng đưa thẳng tài khoản cho client)
Không an toàn, dễ bị giả mạo/phishing.
Chỉ dùng khi client được tin cậy tuyệt đối (như ứng dụng nội bộ).
Ví dụ: Client nội bộ của Microsoft có thể dùng flow này.
4. Client Credentials Flow (Client không đại diện người dùng)
Dùng cho các dịch vụ gọi nhau (machine-to-machine).
Không liên quan đến người dùng, dùng client ID và secret để lấy token.
So sánh OAuth 2.0 và OAuth 1.0
Tiêu chí | OAuth 1.0 | OAuth 2.0 |
Độ phức tạp | Rất phức tạp (ký số) | Đơn giản hơn nhiều |
Khả năng mở rộng | Thấp | Cao |
Hỗ trợ flow | 3 flow cơ bản | Đa dạng hơn |
Ví dụ đăng nhập bằng Google vào một trang web
Giả sử bạn vào một trang web tên là mycoolapp.com, và bạn thấy nút:
"Đăng nhập bằng Google"
Khi bạn nhấn vào nút đó, quy trình OAuth 2.0 sẽ diễn ra theo từng bước như sau:
👤 Các vai trong câu chuyện
Vai trò | Ai trong ví dụ? |
Bạn (Resource Owner) | Chính bạn – người dùng. |
Client (Ứng dụng bên thứ ba) | Website mycoolapp.com |
Authorization Server | Google – nơi xử lý việc xác thực và cấp phép. |
Resource Server | Google – nơi lưu trữ dữ liệu như email, tên, ảnh đại diện... |
🧭 Step by step
✅ Bước 1: Website chuyển hướng bạn đến Google
mycoolapp.com
không hỏi bạn user/password.Nó chuyển bạn đến trang đăng nhập của Google, kèm theo:
App ID (client ID).
Scope (ứng dụng xin quyền gì? Ví dụ: xem email, tên, ảnh).
URL để quay lại sau khi xong.
🧑💻 Bước 2: Bạn đăng nhập và cho phép
Bạn đăng nhập vào Google (nếu chưa đăng nhập).
Google hỏi bạn:
"mycoolapp muốn truy cập tên và địa chỉ email của bạn. Bạn có đồng ý không?"
Nếu bạn bấm “Đồng ý”, Google sẽ:
Gửi bạn quay lại
mycoolapp.com
.Kèm theo một mã tạm thời (authorization code).
🔐 Bước 3: mycoolapp dùng mã đó xin token
mycoolapp.com
lấy mã tạm thời đó và gửi yêu cầu đến Google:"Tôi có mã đây, cho tôi token để gọi API đi!"
Google xác nhận và trả lại:
- Access Token: giống như một vé, cho phép
mycoolapp
gọi API của Google thay bạn.
- Access Token: giống như một vé, cho phép
📡 Bước 4: mycoolapp gọi Google API
Giờ
mycoolapp.com
dùng Access Token này để gọi API của Google, ví dụ:- Lấy tên, email, ảnh đại diện của bạn.
Sau đó nó dùng các thông tin đó để:
Đăng nhập bạn vào.
Tạo tài khoản mới nếu bạn chưa từng đăng nhập.
🧠 Tóm lại
Bước | Diễn ra điều gì? |
Nhấn nút Google | Website chuyển hướng bạn đến Google |
Đăng nhập + đồng ý | Bạn xác nhận cấp quyền |
Nhận mã tạm thời | Website nhận được code |
Đổi mã lấy token | Website xin access token từ Google |
Lấy thông tin người dùng | Website gọi API Google để lấy email, tên... |
💡 Vậy lợi ích là gì?
An toàn: Website không thấy và không lưu mật khẩu Google của bạn.
Tiện lợi: Không cần tạo tài khoản mới.
Có thể thu hồi: Bạn có thể vào tài khoản Google và thu hồi quyền truy cập bất cứ lúc nào.
Okay, có vẻ hơi nhiều chữ rồi nhỉ, chúng ta bắt tay vào code một chút cho giãn gân cốt nhỉ.
Xây dựng một server xác thực OAuth đơn giản bằng Node.js
Đây là hệ thống demo OAuth2 với 2 phần:
super-cute-app
: OAuth2 Server viết bằng Node.js + Express + oauth2orizeclient-app
: Ứng dụng frontend React (Vite) với nút "Đăng nhập bằng super-cute-app"
🧠 Luồng hoạt động (Authorization Code Flow)
Client app hiển thị nút:
Đăng nhập bằng super-cute-app
.Người dùng click → redirect tới
http://localhost:3000/oauth2/authorize
?...
Server hiển thị giao diện đăng nhập vào hệ thống oauth2, người dùng nhập thông tin username/password
Server hiển thị giao diện xác nhận quyền truy cập.
Người dùng xác nhận quyền truy cập.(allow or deny)
Người dùng cho phép → server redirect về
http://localhost:5173/callback?code=
...
Client dùng
code
để đổi lấyaccess_token
qua/oauth2/token
.Dùng
access_token
để gọi các API được bảo vệ.
Cấu trúc thư mục của project OAuth Server như sau:
oauth-server-nodejs/
├── config/ # Cấu hình
├── controllers/ # Logic xử lý
├── models/ # Schema MongoDB
├── routes/ # Định nghĩa API endpoints
├── scripts/ # Các script tiện ích
└── server.js # Entry point
Các tính năng chính:
Xác thực người dùng (Authentication)
Phân quyền truy cập (Authorization)
Quản lý token và phiên đăng nhập
API endpoints cho OAuth2 flow
Kết nối với MongoDB để lưu trữ dữ liệu
Giải thích về project
File cấu hình và khởi động:
server.js
: File chính khởi động server, cấu hình Express, middleware, kết nối database và các routerequire("dotenv").config(); const express = require("express"); const mongoose = require("mongoose"); const passport = require("passport"); const session = require("express-session"); const cors = require("cors"); const app = express(); // Body parsing - multiple approaches for compatibility app.use(express.urlencoded({ extended: true })); app.use(express.json()); //CORS middleware app.use( cors({ origin: "http://localhost:5173", // Client app URL credentials: true, }) ); // Session app.use( session({ secret: process.env.SESSION_SECRET || "oauth2-secret-key", resave: false, saveUninitialized: true, cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }, }) ); // Passport require("./controllers/passport"); app.use(passport.initialize()); app.use(passport.session()); // Routes app.use("/oauth2", require("./routes/index")); // Database connection và start server... // Database setup và start server const MONGODB_URI = `mongodb://${process.env.MONGO_USERNAME}:${process.env.MONGO_PASSWORD}@127.0.0.1:${process.env.MONGO_PORT}/${process.env.MONGO_DATABASE}?authSource=admin`; mongoose .connect(MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, }) .then(() => { console.log("MongoDB connected..."); app.listen(3000, () => { console.log("OAuth server is running on port 3000"); }); }) .catch((err) => { console.error("MongoDB connection error:", err.message); });
package.json
: Quản lý dependencies và scripts của projectdocker-compose.yml
: Cấu hình Docker để containerize ứng dụng
Thư mục config/
:
oauth2.js
: Chứa cấu hình chi tiết cho OAuth2 server, bao gồm các grant types, token handling, và các middleware xác thực.
Thư mục controllers/
:
passport.js
: Cấu hình Passport.js cho việc xác thực người dùng, định nghĩa các strategy và middleware xác thực
Thư mục models/
:
user.js
: Schema MongoDB cho User modelclient.js
: Schema MongoDB cho OAuth Client model
Thư mục routes/
:
index.js
: Định nghĩa tất cả các API endpoints cho OAuth2 flow
Thư mục scripts/
:
- Chứa các script tiện ích tạo mock data
Tổng quan Flow của hệ thống backend OAuth2
Để có thể chạy được project và hiểu luồng, đầu tiên các bạn nên khởi động server và chạy 2 file scripts để tạo dữ liệu mẫu:
Để chạy chúng các bạn sử dụng lệnh
node scripts/createClient.js
node scripts/createUser.js
Nếu các bạn đã từng tích hợp OAuth2 của google, Facebook… thì sẽ có 1 giai đoạn đang ký callback_uri
…, đoạn này code cũng dùng để tạo data mẫu như các hệ thống kia.
Authorization Code Flow:
Client App -> OAuth Server 1. Redirect to /oauth2/identifier 2. User login 3. Redirect to /oauth2/authorize 4. User approve 5. Redirect to client with code 6. Client exchange code for token 7. Client use token to access resources
Token Flow
Client App -> OAuth Server 1. POST to /oauth2/token 2. Server validate credentials 3. Return access token 4. Client use token to access resources
User Info Flow
Client App -> OAuth Server 1. GET /oauth2/userinfo with Bearer token 2. Server validate token 3. Return user information
Okay tôi sẽ mô tả qua về flow nhé:
Bước 1: Client App -> Login (OAuth Server)
username/password
sử dụng data được mock ở bước trên.
POST /oauth2/identifier
Body: {
username: "user",
password: "pass",
client_id: "xxx",
response_type: "code",
redirect_uri: "http://client-app/callback",
scope: "read",
state: "random_state"
}
Code xử lý trong routes/index.js
:
router.post("/identifier", async (req, res) => {
try {
// 1. Tìm user
const user = await User.findOne({ username });
if (!user) {
return res.redirect(`/oauth2/identifier?error=Invalid username...`);
}
// 2. Verify password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.redirect(`/oauth2/identifier?error=Invalid password...`);
}
// 3. Lưu user vào session
req.session.user = user;
// 4. Redirect đến trang authorization
const authUrl = `/oauth2/authorize?client_id=${client_id}...`;
res.redirect(authUrl);
} catch (err) {
// Xử lý lỗi
}
});
Login thành công sẽ chuyển qua bước tiếp theo:
Bước 2: Login (OAuth Server) -> Authorize (OAuth Server)
GET /oauth2/authorize?
client_id=xxx&
response_type=code&
redirect_uri=http://client-app/callback&
scope=read&
state=random_state
Đoạn code sau sẽ xử lý yêu cầu này
// Export authorization handlers
exports.authorization = [
customAuthorization,
(req, res, next) => {
if (req.method === "GET") {
return renderAuthorizationForm(req, res);
}
next();
},
customDecision,
];
customAuthorization
middleware sẽ validate đầu tiên
const customAuthorization = async (req, res, next) => {
// 1. Validate các tham số bắt buộc
if (!client_id || !redirect_uri || !response_type) {
return res.status(400).json({
error: "invalid_request",
error_description: "Missing required parameters"
});
}
// 2. Kiểm tra response_type phải là "code"
if (response_type !== "code") {
return res.status(400).json({
error: "unsupported_response_type",
error_description: "Only authorization code flow is supported"
});
}
// 3. Kiểm tra client có tồn tại
const client = await Client.findOne({ id: client_id });
if (!client) {
return res.status(400).json({
error: "invalid_client",
error_description: "Client not found"
});
}
// 4. Kiểm tra redirect URI có hợp lệ
if (!client.redirectUris.includes(redirect_uri)) {
return res.status(400).json({
error: "invalid_request",
error_description: "Invalid redirect URI"
});
}
// 5. Kiểm tra user đã đăng nhập chưa
if (!req.session.user) {
// Nếu chưa đăng nhập, redirect đến form login
const loginUrl = `/oauth2/identifier?client_id=${client_id}&...`;
return res.redirect(loginUrl);
}
}
Nếu pass qua bước validate thì kiểm tra nếu yêu cầu là GET
thì sẽ render giao diện yêu cầu cấp quyền và tiếp tục
const renderAuthorizationForm = (req, res) => {
const { client, scope, state } = req.oauth2;
const currentUrl = req.originalUrl;
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Authorization</title>
<style>
body { font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; padding: 20px; }
.client-info { background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; }
.buttons { margin: 20px 0; }
button { padding: 12px 24px; margin: 0 10px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
.allow { background: #4CAF50; color: white; }
.deny { background: #f44336; color: white; }
button:hover { opacity: 0.8; }
</style>
</head>
<body>
<h2>🔐 Authorization Request</h2>
<div class="client-info">
<h3>Application Details</h3>
<p><strong>Client:</strong> ${client.name || client.id}</p>
<p><strong>Scope:</strong> ${scope || "basic access"}</p>
<p><strong>Description:</strong> This application is requesting access to your account.</p>
</div>
<p>Do you authorize this application to access your account?</p>
<form method="post" action="${currentUrl}">
<div class="buttons">
<button type="submit" name="authorize" value="allow" class="allow">
✅ Allow Access
</button>
<button type="submit" name="authorize" value="deny" class="deny">
❌ Deny Access
</button>
</div>
</form>
<p><small>You will be redirected back to the application after making your choice.</small></p>
</body>
</html>
`);
};
Cuối cùng hàm customDecision
sẽ được gọi để xử lý tiếp, hàm này dựa vào lựa chọn allow/deny
ở bước trước để đưa ra quyết định tiếp theo, ở khuôn khổ bài viết này tôi sẽ chỉ trình bày best case thôi nhé.
const customDecision = async (req, res) => {
try {
const { authorize } = req.body;
const { client, redirectUri, scope, state, user } = req.oauth2;
if (authorize === "deny") {
// User denied authorization
const errorUrl = new URL(redirectUri);
errorUrl.searchParams.set("error", "access_denied");
errorUrl.searchParams.set(
"error_description",
"User denied authorization"
);
if (state) errorUrl.searchParams.set("state", state);
return res.redirect(errorUrl.toString());
}
if (authorize === "allow") {
// User approved authorization - generate code
const code = crypto.randomBytes(32).toString("hex");
// Use the authenticated user
const userId = user._id;
// Store authorization code
authorizationCodes.set(code, {
clientId: client.id,
userId: userId,
redirectUri: redirectUri,
createdAt: Date.now(),
expiresIn: 10 * 60 * 1000,
});
// Cleanup after 10 minutes
setTimeout(() => {
authorizationCodes.delete(code);
}, 10 * 60 * 1000);
// Redirect back with authorization code
const callbackUrl = new URL(redirectUri);
callbackUrl.searchParams.set("code", code);
if (state) callbackUrl.searchParams.set("state", state);
return res.redirect(callbackUrl.toString());
}
// Should not reach here
res.status(400).json({
error: "invalid_request",
error_description: "Invalid authorization decision",
});
} catch (err) {
res.status(500).json({
error: "server_error",
error_description: "Internal server error",
});
}
};
Hàm này sẽ sinh ra code
tạm và gắn nó vào url callback trả về cho client, đến bước này thì đã quay về client app rồi nhé.
Bước 3: Client App -> Get Token (OAuth Server)
POST /oauth2/token
Body: {
grant_type: "authorization_code",
code: "xxx",
redirect_uri: "http://client-app/callback",
client_id: "xxx",
client_secret: "xxx"
}
Tiếp theo client sẽ sử dụng code
nhận được từ bước trên để đổi lại access-token
, trong bài viết này tôi đã giản lược logic đoạn này là sẽ gọi api này từ client (browser), ở hệ thống thật các bạn nên gửi code này xuống server và call api sang server Oauth từ server nhé.
Đoạn code xử lý api /oauth2/token
như sau.
exports.token = [
passport.authenticate(["oauth2-client-basic"], {
session: false,
}),
server.token(),
server.errorHandler(),
];
Api này sử sử dụng passport
để valid data đầu vào dựa vào data được mock và lưu ở database ở bước trên, strategy được sử dụng trong bài này là “basic”.
passport.use(
"oauth2-client-basic",
new BasicStrategy(async function (clientId, clientSecret, done) {
try {
console.log(`Authenticating client: ${clientId}`);
const client = await Client.findOne({ id: clientId });
if (!client || client.secret !== clientSecret) {
console.log("Client not found or secret does not match");
return done(null, false);
}
return done(null, client);
} catch (err) {
console.log("Error:", err);
return done(err);
}
})
);
server.token()
, sẽ gọi đến hàm exchange
để xác thực tiếp, nếu mọi thứ okay thì sẽ trả về token cho client.
server.exchange(
oauth2orize.exchange.code(async (client, code, redirectUri, done) => {
try {
// 1. Kiểm tra authorization code
const authData = authorizationCodes.get(code);
console.log("authorizationCodes", authorizationCodes);
if (!authData) {
return done(new Error("Invalid or expired authorization code"));
}
// 2. Kiểm tra expiration
if (Date.now() - authData.createdAt > authData.expiresIn) {
authorizationCodes.delete(code);
return done(new Error("Authorization code expired"));
}
// 3. Kiểm tra client có quyền sử dụng code này
if (authData.clientId !== client.id) {
return done(new Error("Invalid client for this authorization code"));
}
// 4. Kiểm tra redirectUri khớp
if (authData.redirectUri !== redirectUri) {
return done(new Error("Redirect URI mismatch"));
}
// 5. Xóa code sau khi sử dụng (one-time use)
authorizationCodes.delete(code);
// 6. Tạo access token
const accessToken = jwt.sign(
{
clientId: client.id,
userId: authData.userId,
type: "access_token",
scope: "read",
},
JWT_SECRET,
{ expiresIn: "1h" }
);
// 7. Tạo refresh token
const refreshToken = jwt.sign(
{
clientId: client.id,
userId: authData.userId,
type: "refresh_token",
},
JWT_SECRET,
{ expiresIn: "7d" }
);
console.log("aaaa", accessToken);
// 8. Trả về tokens
done(null, accessToken, refreshToken, {
expires_in: 3600,
token_type: "Bearer",
});
} catch (err) {
done(err);
}
})
);
Khi client có token thì sẽ process đến bước tiếp theo là sử dụng token để lấy thông tin của user, và show nó lên trình duyệt.
Phần code của Client không có gì nhiều, các bạn có thể đọc lại trong code nhé.
Note: Lưu ý là ở bài viết này chỉ để giới thiệu kiến thức và tham khảo logic hoạt động, không nên sử dụng code trong bài viết để triển khai hệ thống thật nhé, nó không đáp ứng an toàn bảo mật hệ thống thông tin.
Dưới đây là một số hình ảnh của hệ thống.
Bài viết cũng đã khá dài, cám ơn các bạn đã đọc, tôi rất vui nếu các bạn đóng góp bình luận để tôi hoàn thiện code hơn. Đây là link source code để các bạn tham khảo.
Subscribe to my newsletter
Read articles from Eminel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Eminel
Eminel
Hello, my name is Eminel a software engineer specializing in scalable software architecture, microservices, and AI-powered platforms. Passionate about mentoring, performance optimization, and building solutions that drive business success.