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

EminelEminel
16 min read

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 OwnerNgườ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 ServerAPI lưu trữ dữ liệu (Google Drive, Facebook...).
Authorization ServerCấ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:

  1. Client chuyển hướng người dùng đến Authorization Server.

  2. Người dùng đăng nhập → cấp quyền → nhận mã ủy quyền (authorization code).

  3. Client dùng mã này để xin Access Token.

  4. 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.0OAuth 2.0
Độ phức tạpRất phức tạp (ký số)Đơn giản hơn nhiều
Khả năng mở rộngThấpCao
Hỗ trợ flow3 flow cơ bảnĐa dạng hơn

Ví dụ đăng nhập bằng Google vào một trang web

Luồng đăng nhập bằng google

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 ServerGoogle – nơi xử lý việc xác thực và cấp phép.
Resource ServerGoogle – 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.

  • 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.

📡 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ướcDiễn ra điều gì?
Nhấn nút GoogleWebsite 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ờiWebsite nhận được code
Đổi mã lấy tokenWebsite xin access token từ Google
Lấy thông tin người dùngWebsite 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 + oauth2orize

  • client-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)

  1. Client app hiển thị nút: Đăng nhập bằng super-cute-app.

  2. Người dùng click → redirect tới http://localhost:3000/oauth2/authorize?...

  3. 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

  4. Server hiển thị giao diện xác nhận quyền truy cập.

  5. Người dùng xác nhận quyền truy cập.(allow or deny)

  6. Người dùng cho phép → server redirect về http://localhost:5173/callback?code=...

  7. Client dùng code để đổi lấy access_token qua /oauth2/token.

  8. 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 route

      require("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 project

  • docker-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 model

  • client.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.

  1. 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
    
  2. 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
    
  3. 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.

https://github.com/eminel9311/oauth2-sample-nodejs.git

0
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.