กลับไปที่บทความ
Authentication Security Backend Node.js

OAuth 2.0 Authentication กลยุทธ์การยืนยันตัวตนสำหรับแอปเว็บสมัยใหม่

พลากร วรมงคล
11 เมษายน 2568 13 นาที

“คู่มือการใช้งาน OAuth 2.0 อย่างเข้มข้น ครอบคลุมการไหล authorization, OpenID Connect, การใช้งาน Google และ GitHub login, การจัดการ token, และรูปแบบ enterprise SSO”

เจาะลึก: OAuth 2.0 Authentication

OAuth 2.0 คืออะไร?

OAuth 2.0 เป็น authorization framework (RFC 6749) ที่อนุญาตให้แอปพลิเคชันบุคคลที่สามเข้าถึงทรัพยากรของผู้ใช้บนบริการอื่น โดยไม่ต้องให้ผู้ใช้แชร์รหัสผ่านของพวกเขา เมื่อคุณคลิก “Sign in with Google” หรือ “Connect your GitHub account” OAuth 2.0 คือโปรโตคอลที่จัดการความสัมพันธ์ดังกล่าวเบื้องหลัง

ความเข้าใจสำคัญ: OAuth แยกแนวคิดของ authentication (ผู้ใช้นี้คือใคร?) และ authorization (แอปนี้สามารถทำอะไรได้บ้างในนามของผู้ใช้?) OAuth 2.0 เองจัดการกับ authorization เท่านั้น OpenID Connect (OIDC) ซึ่งสร้างบน OAuth 2.0 เพิ่มเลเยอร์ authentication โดยนำเสนอ ID token - JWT ที่มีข้อมูลประจำตัวของผู้ใช้ที่ได้รับการตรวจสอบ

OAuth 2.0 ได้รับการเผยแพร่ในปี 2012 เป็นการแทนที่ OAuth 1.0 ซึ่งต้องใช้การลงนามในการร้องขอที่ซับซ้อน รูปแบบที่ลดความซับซ้อนใช้ bearer tokens และ HTTPS ทำให้การรวมเข้า (integration) ง่ายขึ้นอย่างมีนัยสำคัญในขณะที่ยังคงความปลอดภัยเมื่อนำไปใช้อย่างถูกต้อง

Core Principles

  • Delegated access: ผู้ใช้มอบสิทธิ์ที่จำกัด (scoped permissions) ให้แอปพลิเคชันโดยไม่ต้องแชร์ข้อมูลรับรอง
  • Authorization server as intermediary: บุคคลที่สามที่เชื่อถือได้ (Google, GitHub, Azure AD) จัดการ authentication และ consent จริงๆ
  • Token-based: หลังจากการ authorization dance ดำเนินไป แอปจะได้รับ tokens (access, refresh และ ID token ตามตัวเลือก) เพื่อการโต้ตอบกับ APIs
  • Scoped permissions: แอปพลิเคชันร้องขอเพียงการเข้าถึงที่พวกเขาต้องการ (เช่น read:email, repo:read)

Authorization Code Flow (with PKCE)

นี่คือการไหลที่พบได้บ่อยที่สุดและปลอดภัยที่สุด ใช้โดยเว็บแอป mobile apps และ SPAs

sequenceDiagram
    participant U as User
    participant App as Your Application
    participant AS as Authorization Server (Google/GitHub)
    participant API as Resource API

    U->>App: Click "Sign in with Google"
    App->>App: Generate code_verifier + code_challenge (PKCE)
    App->>AS: Redirect to /authorize?response_type=code&client_id=...&code_challenge=...
    AS->>U: Show login + consent screen
    U->>AS: Enter credentials, approve scopes
    AS->>App: Redirect to callback with ?code=AUTH_CODE
    App->>AS: POST /token {code, client_secret, code_verifier}
    AS-->>App: {access_token, refresh_token, id_token}
    App->>API: GET /userinfo (Authorization: Bearer <access_token>)
    API-->>App: {sub, email, name, picture}
    App->>App: Create/find user, issue session or JWT
    App-->>U: Logged in — redirect to dashboard

มาสำรวจความแตกต่างระหว่าง OAuth 2.0 และ OpenID Connect อย่างละเอียด

OAuth 2.0 vs OpenID Connect: ความแตกต่างที่สำคัญ

นักพัฒนาจำนวนมากสับสนระหว่าง OAuth 2.0 กับ OpenID Connect (OIDC) พวกเขามีความเกี่ยวข้องกัน แต่แก้ปัญหาต่างกัน:

OAuth 2.0 เป็น authorization framework มันตอบคำถาม: “แอปพลิเคชันนี้สามารถเข้าถึงอะไรได้บ้างในนามของผู้ใช้” OAuth มีไว้สำหรับการมอบหมายการเข้าถึงทรัพยากร เช่น การอ่าน GitHub repositories หรือไฟล์ Google Drive

OpenID Connect เป็น authentication layer ที่สร้างขึ้นจาก OAuth 2.0 มันเพิ่มข้อมูลเกี่ยวกับตัวตน มันตอบคำถาม: “ผู้ใช้นี้คือใคร” OIDC นำเสนอ ID token (JWT) ที่มีข้อมูลประจำตัวของผู้ใช้ โดยเปิดใจให้ใช้งาน social login

ในทางปฏิบัติ

  • ใช้ OAuth 2.0 เพียงอย่างเดียว เมื่อคุณต้องการ API access: การรวมกับ Stripe, การเข้าถึง Google Drive, การอ่าน GitHub repos
  • ใช้ OpenID Connect เมื่อใช้งาน “Sign in with Google” หรือ “Sign in with GitHub” - คุณสนใจในการระบุตัวตนของผู้ใช้
  • ใช้ทั้งคู่ ในสถานการณ์ทั่วไป: “Sign in with GitHub” + “อนุญาตให้แอปนี้อ่าน repositories ของคุณ”

Authorization Code Flow with PKCE

Authorization Code Flow เป็นการไหลที่ปลอดภัยที่สุดสำหรับเว็บแอปพลิเคชันและแอป mobile PKCE (Proof Key for Code Exchange) เพิ่มความปลอดภัยเพิ่มเติม โดยเฉพาะสำหรับแอป mobile และ SPA

Authorization Code Flow (with PKCE) - ทีละขั้นตอน

User → Your App → Authorization Server → Your App → Authorization Server → Your App
  ↓        ↓              ↓                   ↓               ↓                ↓
Click    Redirect    User logs in      Exchanges code   Returns tokens    Logged in
Login    to provider & consents       for access token   (access, refresh)

Step 1: Generate Challenge แอปพลิเคชันของคุณสร้างสตริงแบบสุ่ม (code verifier) และสร้าง challenge จากมัน:

import crypto from 'crypto';

function generateCodeChallenge(codeVerifier: string): string {
  return crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = generateCodeChallenge(codeVerifier);
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

public class PKCEUtil {
    public static String generateCodeVerifier() {
        SecureRandom sr = new SecureRandom();
        byte[] code = new byte[32];
        sr.nextBytes(code);
        return bytesToHex(code);
    }

    public static String generateCodeChallenge(String codeVerifier) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(codeVerifier.getBytes("UTF-8"));
        return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    }
}
import hashlib
import os
import base64

def generate_code_verifier() -> str:
    return os.urandom(32).hex()

def generate_code_challenge(code_verifier: str) -> str:
    digest = hashlib.sha256(code_verifier.encode()).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
using System.Security.Cryptography;
using System.Text;

public static class PKCEUtil
{
    public static string GenerateCodeVerifier()
    {
        var bytes = RandomNumberGenerator.GetBytes(32);
        return Convert.ToHexString(bytes).ToLower();
    }

    public static string GenerateCodeChallenge(string codeVerifier)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
        return Base64UrlEncode(hash);
    }

    private static string Base64UrlEncode(byte[] bytes) =>
        Convert.ToBase64String(bytes)
            .Replace('+', '-')
            .Replace('/', '_')
            .TrimEnd('=');
}

Step 2: Redirect to Authorization Server

https://provider.com/authorize?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  response_type=code&
  scope=openid%20profile%20email&
  state=random_state_value&
  code_challenge=CHALLENGE&
  code_challenge_method=S256

Step 3: Authorization Server Redirects Back หลังจากอนุมัติจากผู้ใช้:

https://yourapp.com/callback?
  code=AUTH_CODE&
  state=random_state_value

Step 4: Exchange Code for Tokens backend ของคุณแลกเปลี่ยน code (พิสูจน์ code verifier) สำหรับ tokens:

const response = await fetch('https://provider.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authCode,
    client_id: YOUR_CLIENT_ID,
    client_secret: YOUR_CLIENT_SECRET,
    redirect_uri: 'https://yourapp.com/callback',
    code_verifier: codeVerifier, // Proves we own the challenge
  }),
});

const tokens = await response.json();
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

public Map<String, Object> exchangeCodeForTokens(
        String authCode, String codeVerifier) {
    RestTemplate rest = new RestTemplate();

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type", "authorization_code");
    body.add("code", authCode);
    body.add("client_id", YOUR_CLIENT_ID);
    body.add("client_secret", YOUR_CLIENT_SECRET);
    body.add("redirect_uri", "https://yourapp.com/callback");
    body.add("code_verifier", codeVerifier);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
    ResponseEntity<Map> response = rest.postForEntity(
        "https://provider.com/token", request, Map.class);
    return response.getBody();
}
import httpx

async def exchange_code_for_tokens(auth_code: str, code_verifier: str) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://provider.com/token",
            data={
                "grant_type": "authorization_code",
                "code": auth_code,
                "client_id": YOUR_CLIENT_ID,
                "client_secret": YOUR_CLIENT_SECRET,
                "redirect_uri": "https://yourapp.com/callback",
                "code_verifier": code_verifier,
            },
        )
        response.raise_for_status()
        return response.json()
using System.Net.Http;
using System.Collections.Generic;
using System.Text.Json;

public async Task<JsonDocument> ExchangeCodeForTokensAsync(
    string authCode, string codeVerifier)
{
    using var client = new HttpClient();
    var content = new FormUrlEncodedContent(new Dictionary<string, string>
    {
        ["grant_type"] = "authorization_code",
        ["code"] = authCode,
        ["client_id"] = YOUR_CLIENT_ID,
        ["client_secret"] = YOUR_CLIENT_SECRET,
        ["redirect_uri"] = "https://yourapp.com/callback",
        ["code_verifier"] = codeVerifier,
    });

    var response = await client.PostAsync("https://provider.com/token", content);
    response.EnsureSuccessStatusCode();
    var json = await response.Content.ReadAsStringAsync();
    return JsonDocument.Parse(json);
}

Grant Types อื่น ๆ: ข้อมูลอ้างอิงด่วน

Client Credentials Flow

สำหรับ authentication แบบ server-to-server เมื่อไม่มีผู้ใช้เกี่ยวข้อง:

const response = await fetch('https://provider.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: YOUR_CLIENT_ID,
    client_secret: YOUR_CLIENT_SECRET,
    scope: 'api.read api.write',
  }),
});
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", YOUR_CLIENT_ID);
body.add("client_secret", YOUR_CLIENT_SECRET);
body.add("scope", "api.read api.write");

HttpEntity<MultiValueMap<String, String>> request =
    new HttpEntity<>(body, headers);
ResponseEntity<Map> response = rest.postForEntity(
    "https://provider.com/token", request, Map.class);
async with httpx.AsyncClient() as client:
    response = await client.post(
        "https://provider.com/token",
        data={
            "grant_type": "client_credentials",
            "client_id": YOUR_CLIENT_ID,
            "client_secret": YOUR_CLIENT_SECRET,
            "scope": "api.read api.write",
        },
    )
    response.raise_for_status()
    tokens = response.json()
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
    ["grant_type"] = "client_credentials",
    ["client_id"] = YOUR_CLIENT_ID,
    ["client_secret"] = YOUR_CLIENT_SECRET,
    ["scope"] = "api.read api.write",
});
var response = await client.PostAsync("https://provider.com/token", content);
response.EnsureSuccessStatusCode();

Use cases: CI/CD pipelines เข้าถึง APIs, backend services สื่อสารระหว่างกัน, scheduled jobs

Device Code Flow

สำหรับอุปกรณ์ที่ไม่มี browser (smart TVs, IoT devices, CLIs):

อุปกรณ์แสดง code ให้ผู้ใช้ ซึ่งป้อน code บนอุปกรณ์อื่น อุปกรณ์โพล (polling) เพื่อรอการอนุมัติ

Implicit Flow (Deprecated)

Legacy flow ส่งคืน tokens โดยตรงใน URL อย่าใช้สิ่งนี้ - มันเสี่ยงต่อ token exposure ใน browser history และ referer headers Authorization Code Flow with PKCE เป็นมาตรฐานสำหรับ SPAs ขณะนี้

การใช้งาน Google OAuth with Passport.js

นี่คือการใช้งานที่สมบูรณ์และพร้อมใช้งานในสภาพแวดล้อมจริง โดยใช้ Passport.js:

Installation

npm install passport passport-google-oauth20 express-session
npm install -D @types/passport-google-oauth20

Configuration

// src/config/passport.ts
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { User } from '@/models/user';
import { findOrCreateUser } from '@/services/auth';

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      callbackURL: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback',
      scope: ['profile', 'email'],
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // Find or create user
        const user = await findOrCreateUser({
          provider: 'google',
          providerId: profile.id,
          email: profile.emails?.[0]?.value,
          name: profile.displayName,
          picture: profile.photos?.[0]?.value,
          accessToken,
          refreshToken,
        });

        done(null, user);
      } catch (error) {
        done(error);
      }
    }
  )
);

// Serialize user for session
passport.serializeUser((user: any, done) => {
  done(null, user.id);
});

passport.deserializeUser(async (id: string, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);
  } catch (error) {
    done(error);
  }
});

export default passport;
// Spring Boot — application.properties + OAuth2 auto-configuration
// application.properties:
// spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
// spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
// spring.security.oauth2.client.registration.google.scope=profile,email

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomOAuth2UserService oAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(info -> info
                    .userService(oAuth2UserService))
                .successHandler(this::onAuthSuccess)
                .failureUrl("/login?error=true"))
            .logout(logout -> logout
                .logoutUrl("/auth/logout")
                .deleteCookies("JSESSIONID"));
        return http.build();
    }

    private void onAuthSuccess(HttpServletRequest req,
            HttpServletResponse res, Authentication auth) throws IOException {
        // Issue session token, redirect to dashboard
        res.sendRedirect("/dashboard");
    }
}

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    @Autowired private UserRepository userRepo;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest req) {
        OAuth2User oauthUser = super.loadUser(req);
        String provider = req.getClientRegistration().getRegistrationId();
        String providerId = oauthUser.getName();
        String email = oauthUser.getAttribute("email");
        String name = oauthUser.getAttribute("name");
        String picture = oauthUser.getAttribute("picture");

        userRepo.findByProviderAndProviderId(provider, providerId)
            .orElseGet(() -> userRepo.save(User.builder()
                .provider(provider).providerId(providerId)
                .email(email).name(name).picture(picture)
                .emailVerified(true).build()));
        return oauthUser;
    }
}
# FastAPI + Authlib
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config

config = Config(".env")
oauth = OAuth(config)

oauth.register(
    name="google",
    client_id=config("GOOGLE_CLIENT_ID"),
    client_secret=config("GOOGLE_CLIENT_SECRET"),
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid profile email"},
)

@router.get("/auth/google")
async def google_login(request: Request):
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri)

@router.get("/auth/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.google.authorize_access_token(request)
    profile = token.get("userinfo")

    user = find_or_create_user(db, provider="google",
        provider_id=profile["sub"], email=profile["email"],
        name=profile.get("name"), picture=profile.get("picture"),
        access_token=token["access_token"])
    
    session_token = create_session(db, user.id)
    response = RedirectResponse("/dashboard")
    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=7 * 24 * 60 * 60)
    return response
// Program.cs — ASP.NET Core
builder.Services.AddAuthentication(options => {
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie()
.AddGoogle(options => {
    options.ClientId = builder.Configuration["Google:ClientId"]!;
    options.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.SaveTokens = true;
    options.Events.OnCreatingTicket = async ctx => {
        var userService = ctx.HttpContext.RequestServices
            .GetRequiredService<IUserService>();
        await userService.FindOrCreateAsync(new OAuthProfile {
            Provider = "google",
            ProviderId = ctx.Principal!.FindFirstValue(ClaimTypes.NameIdentifier)!,
            Email = ctx.Principal.FindFirstValue(ClaimTypes.Email)!,
            Name = ctx.Principal.FindFirstValue(ClaimTypes.Name),
            Picture = ctx.Principal.FindFirstValue("picture"),
            AccessToken = ctx.AccessToken,
            RefreshToken = ctx.RefreshToken,
        });
    };
});

Routes

// src/routes/auth.ts
import express from 'express';
import passport from 'passport';
import { generateSessionToken } from '@/utils/jwt';

const router = express.Router();

// Initiate Google login
router.get(
  '/google',
  passport.authenticate('google', {
    scope: ['profile', 'email'],
    accessType: 'offline', // Request refresh token
  })
);

// Google callback
router.get(
  '/google/callback',
  passport.authenticate('google', { failureRedirect: '/login?error=true' }),
  (req, res) => {
    // User is authenticated
    const sessionToken = generateSessionToken(req.user);
    
    // Set secure, httpOnly cookie
    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    });

    res.redirect('/dashboard');
  }
);

// Logout
router.post('/logout', (req, res) => {
  req.logout((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.clearCookie('sessionToken');
    res.json({ success: true });
  });
});

export default router;
@RestController
@RequestMapping("/auth")
public class AuthController {

    @GetMapping("/google")
    public void googleLogin(HttpServletResponse response) throws IOException {
        // Spring Security handles redirect automatically via OAuth2LoginConfigurer
        response.sendRedirect("/oauth2/authorization/google");
    }

    // Spring Security handles /login/oauth2/code/google callback automatically.
    // Customize via successHandler in SecurityConfig.

    @PostMapping("/logout")
    public ResponseEntity<Map<String, Object>> logout(
            HttpServletRequest req, HttpServletResponse res,
            Authentication auth) throws Exception {
        new SecurityContextLogoutHandler().logout(req, res, auth);
        // Clear session cookie
        ResponseCookie cookie = ResponseCookie.from("sessionToken", "")
            .httpOnly(true).secure(true).maxAge(0).path("/").build();
        res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
        return ResponseEntity.ok(Map.of("success", true));
    }
}
from fastapi import APIRouter, Request, Response
from starlette.responses import RedirectResponse, JSONResponse

router = APIRouter()

@router.get("/auth/google")
async def google_login(request: Request):
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri,
        access_type="offline")

@router.get("/auth/google/callback", name="google_callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.google.authorize_access_token(request)
    profile = token.get("userinfo")
    user = find_or_create_user(db, provider="google",
        provider_id=profile["sub"], email=profile["email"],
        name=profile.get("name"), access_token=token["access_token"])
    session_token = create_session(db, user.id)
    resp = RedirectResponse("/dashboard")
    resp.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=7 * 24 * 60 * 60)
    return resp

@router.post("/auth/logout")
async def logout(response: Response):
    response.delete_cookie("sessionToken")
    return {"success": True}
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
    [HttpGet("google")]
    public IActionResult GoogleLogin() =>
        Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
            GoogleDefaults.AuthenticationScheme);

    [HttpGet("google/callback")]
    public async Task<IActionResult> GoogleCallback()
    {
        var result = await HttpContext.AuthenticateAsync(
            CookieAuthenticationDefaults.AuthenticationScheme);
        if (!result.Succeeded)
            return Redirect("/login?error=true");
        return Redirect("/dashboard");
    }

    [HttpPost("logout")]
    public async Task<IActionResult> Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        Response.Cookies.Delete("sessionToken");
        return Ok(new { success = true });
    }
}

User Service

// src/services/auth.ts
import { User } from '@/models/user';
import bcrypt from 'bcrypt';

export async function findOrCreateUser(profile: {
  provider: string;
  providerId: string;
  email: string;
  name: string;
  picture?: string;
  accessToken: string;
  refreshToken?: string;
}) {
  // Look for existing user with this OAuth provider
  let user = await User.findOne({
    'oauth.provider': profile.provider,
    'oauth.providerId': profile.providerId,
  });

  if (user) {
    // Update tokens if user exists
    user.oauth.accessToken = profile.accessToken;
    if (profile.refreshToken) {
      user.oauth.refreshToken = profile.refreshToken;
    }
    await user.save();
    return user;
  }

  // Check if user exists by email
  user = await User.findOne({ email: profile.email });

  if (user) {
    // Link OAuth to existing user
    user.oauth = {
      provider: profile.provider,
      providerId: profile.providerId,
      accessToken: profile.accessToken,
      refreshToken: profile.refreshToken,
    };
    await user.save();
    return user;
  }

  // Create new user
  user = new User({
    email: profile.email,
    name: profile.name,
    picture: profile.picture,
    emailVerified: true, // OAuth providers verify email
    oauth: {
      provider: profile.provider,
      providerId: profile.providerId,
      accessToken: profile.accessToken,
      refreshToken: profile.refreshToken,
    },
  });

  await user.save();
  return user;
}
@Service
@Transactional
public class AuthService {

    @Autowired private UserRepository userRepo;

    public User findOrCreateUser(OAuthProfile profile) {
        // Look for existing user with this OAuth provider
        return userRepo.findByProviderAndProviderId(
                profile.provider(), profile.providerId())
            .map(user -> {
                user.setAccessToken(profile.accessToken());
                if (profile.refreshToken() != null)
                    user.setRefreshToken(profile.refreshToken());
                return userRepo.save(user);
            })
            .orElseGet(() -> userRepo.findByEmail(profile.email())
                .map(user -> {
                    // Link OAuth to existing user
                    user.setProvider(profile.provider());
                    user.setProviderId(profile.providerId());
                    user.setAccessToken(profile.accessToken());
                    user.setRefreshToken(profile.refreshToken());
                    return userRepo.save(user);
                })
                .orElseGet(() -> userRepo.save(User.builder()
                    .email(profile.email())
                    .name(profile.name())
                    .picture(profile.picture())
                    .emailVerified(true)
                    .provider(profile.provider())
                    .providerId(profile.providerId())
                    .accessToken(profile.accessToken())
                    .refreshToken(profile.refreshToken())
                    .build())));
    }
}
from sqlalchemy.orm import Session
from models import User

def find_or_create_user(
    db: Session,
    provider: str,
    provider_id: str,
    email: str,
    name: str,
    picture: str | None = None,
    access_token: str = "",
    refresh_token: str | None = None,
) -> User:
    # Look for existing user with this OAuth provider
    user = db.query(User).filter_by(
        provider=provider, provider_id=provider_id).first()

    if user:
        user.access_token = access_token
        if refresh_token:
            user.refresh_token = refresh_token
        db.commit()
        return user

    # Check if user exists by email
    user = db.query(User).filter_by(email=email).first()
    if user:
        user.provider = provider
        user.provider_id = provider_id
        user.access_token = access_token
        user.refresh_token = refresh_token
        db.commit()
        return user

    # Create new user
    user = User(
        email=email, name=name, picture=picture,
        email_verified=True, provider=provider,
        provider_id=provider_id, access_token=access_token,
        refresh_token=refresh_token,
    )
    db.add(user)
    db.commit()
    return user
public class AuthService : IAuthService
{
    private readonly AppDbContext _db;

    public AuthService(AppDbContext db) => _db = db;

    public async Task<User> FindOrCreateUserAsync(OAuthProfile profile)
    {
        // Look for existing user with this OAuth provider
        var user = await _db.Users.FirstOrDefaultAsync(u =>
            u.Provider == profile.Provider &&
            u.ProviderId == profile.ProviderId);

        if (user != null)
        {
            user.AccessToken = profile.AccessToken;
            if (profile.RefreshToken != null)
                user.RefreshToken = profile.RefreshToken;
            await _db.SaveChangesAsync();
            return user;
        }

        // Check if user exists by email
        user = await _db.Users.FirstOrDefaultAsync(u => u.Email == profile.Email);
        if (user != null)
        {
            user.Provider = profile.Provider;
            user.ProviderId = profile.ProviderId;
            user.AccessToken = profile.AccessToken;
            user.RefreshToken = profile.RefreshToken;
            await _db.SaveChangesAsync();
            return user;
        }

        // Create new user
        user = new User {
            Email = profile.Email, Name = profile.Name,
            Picture = profile.Picture, EmailVerified = true,
            Provider = profile.Provider, ProviderId = profile.ProviderId,
            AccessToken = profile.AccessToken, RefreshToken = profile.RefreshToken,
        };
        _db.Users.Add(user);
        await _db.SaveChangesAsync();
        return user;
    }
}

การใช้งาน GitHub OAuth

GitHub OAuth เป็นไปตามรูปแบบเดียวกัน แต่มีการกำหนดค่าเฉพาะของ provider:

// src/config/passport-github.ts
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { findOrCreateUser } from '@/services/auth';

passport.use(
  'github',
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/auth/github/callback',
      scope: ['user:email', 'repo'], // Request specific scopes
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        const primaryEmail = profile.emails?.find(e => e.primary)?.value || 
                           profile.emails?.[0]?.value;

        const user = await findOrCreateUser({
          provider: 'github',
          providerId: String(profile.id),
          email: primaryEmail!,
          name: profile.displayName || profile.username || '',
          picture: profile.photos?.[0]?.value,
          accessToken,
        });

        done(null, user);
      } catch (error) {
        done(error);
      }
    }
  )
);
// Spring Boot — application.properties
// spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID}
// spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET}
// spring.security.oauth2.client.registration.github.scope=user:email,repo

@Service
public class GitHubOAuth2UserService extends DefaultOAuth2UserService {
    @Autowired private UserRepository userRepo;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest req) {
        OAuth2User oauthUser = super.loadUser(req);
        String providerId = String.valueOf(oauthUser.getAttribute("id"));
        String name = oauthUser.getAttribute("name");
        if (name == null) name = oauthUser.getAttribute("login");
        String picture = oauthUser.getAttribute("avatar_url");
        String email = oauthUser.getAttribute("email");
        String accessToken = req.getAccessToken().getTokenValue();

        userRepo.findByProviderAndProviderId("github", providerId)
            .orElseGet(() -> userRepo.save(User.builder()
                .provider("github").providerId(providerId)
                .email(email).name(name).picture(picture)
                .accessToken(accessToken)
                .emailVerified(true).build()));
        return oauthUser;
    }
}
oauth.register(
    name="github",
    client_id=config("GITHUB_CLIENT_ID"),
    client_secret=config("GITHUB_CLIENT_SECRET"),
    access_token_url="https://github.com/login/oauth/access_token",
    authorize_url="https://github.com/login/oauth/authorize",
    api_base_url="https://api.github.com/",
    client_kwargs={"scope": "user:email repo"},
)

@router.get("/auth/github/callback")
async def github_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.github.authorize_access_token(request)
    profile_resp = await oauth.github.get("user", token=token)
    profile = profile_resp.json()

    emails_resp = await oauth.github.get("user/emails", token=token)
    emails = emails_resp.json()
    primary_email = next(
        (e["email"] for e in emails if e.get("primary")),
        profile.get("email"))

    user = find_or_create_user(db, provider="github",
        provider_id=str(profile["id"]),
        email=primary_email,
        name=profile.get("name") or profile.get("login", ""),
        picture=profile.get("avatar_url"),
        access_token=token["access_token"])
    
    session_token = create_session(db, user.id)
    resp = RedirectResponse("/dashboard")
    resp.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=7 * 24 * 60 * 60)
    return resp
// Program.cs
builder.Services.AddAuthentication()
    .AddGitHub(options => {
        options.ClientId = builder.Configuration["GitHub:ClientId"]!;
        options.ClientSecret = builder.Configuration["GitHub:ClientSecret"]!;
        options.Scope.Add("user:email");
        options.Scope.Add("repo");
        options.SaveTokens = true;
        options.Events.OnCreatingTicket = async ctx => {
            var userService = ctx.HttpContext.RequestServices
                .GetRequiredService<IUserService>();
            var primaryEmail = ctx.Principal!
                .FindFirstValue(ClaimTypes.Email) ?? "";
            await userService.FindOrCreateAsync(new OAuthProfile {
                Provider = "github",
                ProviderId = ctx.Principal.FindFirstValue(ClaimTypes.NameIdentifier)!,
                Email = primaryEmail,
                Name = ctx.Principal.FindFirstValue(ClaimTypes.Name),
                Picture = ctx.Principal.FindFirstValue("urn:github:avatar"),
                AccessToken = ctx.AccessToken,
            });
        };
    });

Routes เหมือนกับ Google แต่เปลี่ยนชื่อ provider:

// GitHub routes
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));

router.get(
  '/github/callback',
  passport.authenticate('github', { failureRedirect: '/login' }),
  (req, res) => {
    const sessionToken = generateSessionToken(req.user);
    res.cookie('sessionToken', sessionToken, { /* ... */ });
    res.redirect('/dashboard');
  }
);
@GetMapping("/github")
public void githubLogin(HttpServletResponse response) throws IOException {
    response.sendRedirect("/oauth2/authorization/github");
}

// Spring Security handles /login/oauth2/code/github automatically.
// Success redirects to /dashboard via successHandler in SecurityConfig.
@router.get("/auth/github")
async def github_login(request: Request):
    redirect_uri = request.url_for("github_callback")
    return await oauth.github.authorize_redirect(request, redirect_uri)
[HttpGet("github")]
public IActionResult GitHubLogin() =>
    Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
        "GitHub");

การเข้าถึง GitHub Repositories ของผู้ใช้

หลังจากการ authenticate แล้ว ให้ใช้ access token ที่เก็บไว้เพื่อเรียก GitHub API:

// src/services/github.ts
export async function getUserRepositories(accessToken: string) {
  const response = await fetch('https://api.github.com/user/repos', {
    headers: {
      Authorization: `token ${accessToken}`,
      Accept: 'application/vnd.github.v3+json',
    },
  });

  if (!response.ok) {
    throw new Error(`GitHub API error: ${response.statusText}`);
  }

  return response.json();
}
@Service
public class GitHubService {
    private final RestTemplate restTemplate = new RestTemplate();

    public List<Map<String, Object>> getUserRepositories(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "token " + accessToken);
        headers.set("Accept", "application/vnd.github.v3+json");

        ResponseEntity<List> response = restTemplate.exchange(
            "https://api.github.com/user/repos",
            HttpMethod.GET,
            new HttpEntity<>(headers),
            List.class);

        if (!response.getStatusCode().is2xxSuccessful()) {
            throw new RuntimeException("GitHub API error: " + response.getStatusCode());
        }
        return response.getBody();
    }
}
import httpx
from typing import Any

async def get_user_repositories(access_token: str) -> list[dict[str, Any]]:
    async with httpx.AsyncClient() as client:
        response = await client.get(
            "https://api.github.com/user/repos",
            headers={
                "Authorization": f"token {access_token}",
                "Accept": "application/vnd.github.v3+json",
            },
        )
        if not response.is_success:
            raise RuntimeError(f"GitHub API error: {response.status_code}")
        return response.json()
public class GitHubService
{
    private readonly HttpClient _http;

    public GitHubService(HttpClient http) => _http = http;

    public async Task<List<JsonElement>> GetUserRepositoriesAsync(string accessToken)
    {
        using var request = new HttpRequestMessage(
            HttpMethod.Get, "https://api.github.com/user/repos");
        request.Headers.Add("Authorization", $"token {accessToken}");
        request.Headers.Add("Accept", "application/vnd.github.v3+json");
        request.Headers.Add("User-Agent", "MyApp/1.0");

        var response = await _http.SendAsync(request);
        response.EnsureSuccessStatusCode();
        var json = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<List<JsonElement>>(json)!;
    }
}

การจัดการ OAuth Tokens

OAuth นำเสนออยู่สามประเภท tokens:

Access Token

  • Short-lived (โดยทั่วไป 1 ชั่วโมง)
  • ใช้เพื่อ authenticate API requests
  • ควรจัดเก็บอย่างปลอดภัย
  • ไม่ควรเปิดให้ browser เข้าถึงใน localStorage
// Use access token for API requests
const headers = {
  Authorization: `Bearer ${accessToken}`,
};
// Use access token for API requests
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
# Use access token for API requests
headers = {"Authorization": f"Bearer {access_token}"}
// Use access token for API requests
httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

Refresh Token

  • Long-lived (วันถึงเดือน)
  • แลกเปลี่ยนเพื่อเรียก access token ใหม่เมื่อหมดอายุ
  • ควรเก็บอย่างปลอดภัย (httpOnly cookie หรือ secure storage)
  • ไม่ควรส่งไปยัง browser

ID Token

  • OpenID Connect เท่านั้น
  • มีข้อมูลประจำตัวของผู้ใช้ (name, email, picture, ฯลฯ)
  • รูปแบบ JWT สามารถ decode ได้ที่ฝั่ง client (แต่ต้อง verify signature ที่ฝั่ง server เสมอ)
// Decode ID token (client-side only for UI)
const decoded = jwtDecode(idToken);
console.log(decoded.email, decoded.name);

Token Refresh Implementation

// src/services/token.ts
export async function refreshAccessToken(refreshToken: string) {
  const response = await fetch('https://provider.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });

  if (!response.ok) {
    throw new Error('Token refresh failed');
  }

  const data = await response.json();
  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token || refreshToken,
    expiresIn: data.expires_in,
  };
}

// Middleware to refresh token if needed
export async function ensureValidToken(user: any) {
  if (!user.oauth?.accessToken) {
    throw new Error('No access token');
  }

  // Check if token expires in less than 5 minutes
  const expiresAt = user.oauth.accessTokenExpiresAt;
  if (expiresAt && new Date() > new Date(expiresAt.getTime() - 5 * 60 * 1000)) {
    const refreshed = await refreshAccessToken(user.oauth.refreshToken);
    user.oauth.accessToken = refreshed.accessToken;
    user.oauth.accessTokenExpiresAt = new Date(Date.now() + refreshed.expiresIn * 1000);
    await user.save();
  }

  return user.oauth.accessToken;
}
@Service
public class TokenService {
    @Autowired private UserRepository userRepo;
    private final RestTemplate restTemplate = new RestTemplate();

    @Value("${oauth.client-id}") private String clientId;
    @Value("${oauth.client-secret}") private String clientSecret;

    public TokenResponse refreshAccessToken(String refreshToken) {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "refresh_token");
        body.add("refresh_token", refreshToken);
        body.add("client_id", clientId);
        body.add("client_secret", clientSecret);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        ResponseEntity<Map> resp = restTemplate.postForEntity(
            "https://provider.com/token",
            new HttpEntity<>(body, headers), Map.class);

        if (!resp.getStatusCode().is2xxSuccessful())
            throw new RuntimeException("Token refresh failed");

        Map<?, ?> data = resp.getBody();
        String newRefresh = data.containsKey("refresh_token")
            ? (String) data.get("refresh_token") : refreshToken;
        return new TokenResponse(
            (String) data.get("access_token"),
            newRefresh,
            (Integer) data.get("expires_in"));
    }

    public String ensureValidToken(User user) {
        if (user.getAccessToken() == null)
            throw new IllegalStateException("No access token");

        Instant expiresAt = user.getAccessTokenExpiresAt();
        if (expiresAt != null &&
                Instant.now().isAfter(expiresAt.minusSeconds(300))) {
            TokenResponse refreshed = refreshAccessToken(user.getRefreshToken());
            user.setAccessToken(refreshed.accessToken());
            user.setAccessTokenExpiresAt(
                Instant.now().plusSeconds(refreshed.expiresIn()));
            userRepo.save(user);
        }
        return user.getAccessToken();
    }
}
import httpx
from datetime import datetime, timedelta, timezone

async def refresh_access_token(refresh_token: str) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://provider.com/token",
            data={
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
                "client_id": settings.OAUTH_CLIENT_ID,
                "client_secret": settings.OAUTH_CLIENT_SECRET,
            },
        )
        if not response.is_success:
            raise RuntimeError("Token refresh failed")
        data = response.json()
        return {
            "access_token": data["access_token"],
            "refresh_token": data.get("refresh_token", refresh_token),
            "expires_in": data["expires_in"],
        }

async def ensure_valid_token(user: User, db: Session) -> str:
    if not user.access_token:
        raise ValueError("No access token")

    expires_at = user.access_token_expires_at
    if expires_at and datetime.now(timezone.utc) > expires_at - timedelta(minutes=5):
        refreshed = await refresh_access_token(user.refresh_token)
        user.access_token = refreshed["access_token"]
        user.access_token_expires_at = (
            datetime.now(timezone.utc) + timedelta(seconds=refreshed["expires_in"]))
        db.commit()

    return user.access_token
public class TokenService
{
    private readonly HttpClient _http;
    private readonly AppDbContext _db;
    private readonly IConfiguration _config;

    public TokenService(HttpClient http, AppDbContext db, IConfiguration config)
    { _http = http; _db = db; _config = config; }

    public async Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken)
    {
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["grant_type"] = "refresh_token",
            ["refresh_token"] = refreshToken,
            ["client_id"] = _config["OAuth:ClientId"]!,
            ["client_secret"] = _config["OAuth:ClientSecret"]!,
        });

        var response = await _http.PostAsync("https://provider.com/token", content);
        response.EnsureSuccessStatusCode();
        var data = await response.Content.ReadFromJsonAsync<JsonDocument>();
        var root = data!.RootElement;

        return new TokenResponse(
            AccessToken: root.GetProperty("access_token").GetString()!,
            RefreshToken: root.TryGetProperty("refresh_token", out var rt)
                ? rt.GetString()! : refreshToken,
            ExpiresIn: root.GetProperty("expires_in").GetInt32());
    }

    public async Task<string> EnsureValidTokenAsync(User user)
    {
        if (user.AccessToken == null)
            throw new InvalidOperationException("No access token");

        if (user.AccessTokenExpiresAt.HasValue &&
            DateTimeOffset.UtcNow > user.AccessTokenExpiresAt.Value.AddMinutes(-5))
        {
            var refreshed = await RefreshAccessTokenAsync(user.RefreshToken!);
            user.AccessToken = refreshed.AccessToken;
            user.AccessTokenExpiresAt = DateTimeOffset.UtcNow
                .AddSeconds(refreshed.ExpiresIn);
            await _db.SaveChangesAsync();
        }
        return user.AccessToken;
    }
}

การจัดการกับการสร้างผู้ใช้และการเชื่อมโยง

เมื่อผู้ใช้ authenticate ด้วย OAuth เป็นครั้งแรก คุณต้องตัดสินใจ:

  1. สร้างบัญชีใหม่ทันที
  2. ตรวจสอบกับ email ที่มีอยู่
  3. ต้องการ account linking
export async function findOrCreateUser(profile: OAuthProfile) {
  // Strategy: Link by OAuth provider first, then by email
  
  let user = await User.findOne({
    $or: [
      { 'oauth.provider': profile.provider, 'oauth.providerId': profile.providerId },
      { email: profile.email }, // Link by email if not yet linked
    ],
  });

  if (!user) {
    // Create new user
    user = new User({
      email: profile.email,
      name: profile.name,
      emailVerified: true, // Assume OAuth provider verified email
    });
  } else if (!user.oauth) {
    // User exists by email, link the OAuth account
    user.oauth = {
      provider: profile.provider,
      providerId: profile.providerId,
    };
  }

  // Always update tokens
  user.oauth.accessToken = profile.accessToken;
  user.oauth.refreshToken = profile.refreshToken;
  await user.save();

  return user;
}
@Transactional
public User findOrCreateUser(OAuthProfile profile) {
    // Link by OAuth provider first, then by email
    User user = userRepo
        .findByProviderAndProviderId(profile.provider(), profile.providerId())
        .or(() -> userRepo.findByEmail(profile.email()))
        .orElse(null);

    if (user == null) {
        user = User.builder()
            .email(profile.email())
            .name(profile.name())
            .emailVerified(true)
            .build();
    } else if (user.getProvider() == null) {
        // User exists by email, link the OAuth account
        user.setProvider(profile.provider());
        user.setProviderId(profile.providerId());
    }

    // Always update tokens
    user.setAccessToken(profile.accessToken());
    user.setRefreshToken(profile.refreshToken());
    return userRepo.save(user);
}
def find_or_create_user(db: Session, profile: OAuthProfile) -> User:
    # Link by OAuth provider first, then by email
    user = (db.query(User)
        .filter_by(provider=profile.provider, provider_id=profile.provider_id)
        .first()
        or db.query(User).filter_by(email=profile.email).first())

    if user is None:
        user = User(
            email=profile.email,
            name=profile.name,
            email_verified=True,
        )
        db.add(user)
    elif user.provider is None:
        user.provider = profile.provider
        user.provider_id = profile.provider_id

    user.access_token = profile.access_token
    user.refresh_token = profile.refresh_token
    db.commit()
    return user
public async Task<User> FindOrCreateUserAsync(OAuthProfile profile)
{
    var user = await _db.Users.FirstOrDefaultAsync(u =>
        (u.Provider == profile.Provider && u.ProviderId == profile.ProviderId)
        || u.Email == profile.Email);

    if (user == null)
    {
        user = new User {
            Email = profile.Email,
            Name = profile.Name,
            EmailVerified = true,
        };
        _db.Users.Add(user);
    }
    else if (user.Provider == null)
    {
        user.Provider = profile.Provider;
        user.ProviderId = profile.ProviderId;
    }

    user.AccessToken = profile.AccessToken;
    user.RefreshToken = profile.RefreshToken;
    await _db.SaveChangesAsync();
    return user;
}

Strategy: Require Explicit Linking

เพื่อการควบคุมที่ดีขึ้น ให้แสดงหน้า linking:

// Route: Display linking page
router.get('/auth/google/callback', (req, res) => {
  const profile = req.user; // From Passport verify callback
  
  // Check if user exists
  const existingUser = await User.findOne({ email: profile.email });
  
  if (existingUser && !existingUser.oauth) {
    // Show linking page
    const token = generateLinkingToken(profile);
    return res.redirect(`/auth/link?token=${token}`);
  }
  
  // Otherwise, normal flow
});

// Linking endpoint
router.post('/auth/link', authenticateUser, async (req, res) => {
  const linkingToken = req.body.token;
  const profile = decodeLinkingToken(linkingToken);
  
  req.user.oauth = {
    provider: profile.provider,
    providerId: profile.providerId,
    accessToken: profile.accessToken,
  };
  
  await req.user.save();
  res.json({ success: true });
});
@GetMapping("/auth/google/callback")
public RedirectView googleCallback(Authentication auth,
        HttpServletRequest req) {
    OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) auth;
    String email = token.getPrincipal().getAttribute("email");

    Optional<User> existingUser = userRepo.findByEmail(email);
    if (existingUser.isPresent() && existingUser.get().getProvider() == null) {
        String linkingToken = generateLinkingToken(token.getPrincipal());
        return new RedirectView("/auth/link?token=" + linkingToken);
    }
    return new RedirectView("/dashboard");
}

@PostMapping("/auth/link")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> linkAccount(
        @RequestBody LinkRequest body,
        @AuthenticationPrincipal UserDetails currentUser) {
    OAuthProfile profile = decodeLinkingToken(body.token());
    User user = userRepo.findByEmail(currentUser.getUsername()).orElseThrow();
    user.setProvider(profile.provider());
    user.setProviderId(profile.providerId());
    user.setAccessToken(profile.accessToken());
    userRepo.save(user);
    return ResponseEntity.ok(Map.of("success", true));
}
@router.get("/auth/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.google.authorize_access_token(request)
    profile = token.get("userinfo")

    existing_user = db.query(User).filter_by(email=profile["email"]).first()
    if existing_user and existing_user.provider is None:
        linking_token = generate_linking_token(profile)
        return RedirectResponse(f"/auth/link?token={linking_token}")

    # Normal flow
    user = find_or_create_user(db, provider="google", ...)
    return create_session_response(user)

@router.post("/auth/link")
async def link_account(
    body: LinkRequest,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    profile = decode_linking_token(body.token)
    current_user.provider = profile["provider"]
    current_user.provider_id = profile["provider_id"]
    current_user.access_token = profile["access_token"]
    db.commit()
    return {"success": True}
[HttpGet("google/callback")]
public async Task<IActionResult> GoogleCallback()
{
    var result = await HttpContext.AuthenticateAsync(
        CookieAuthenticationDefaults.AuthenticationScheme);
    var email = result.Principal?.FindFirstValue(ClaimTypes.Email);

    var existingUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
    if (existingUser != null && existingUser.Provider == null)
    {
        var linkingToken = GenerateLinkingToken(result.Principal!);
        return Redirect($"/auth/link?token={linkingToken}");
    }
    return Redirect("/dashboard");
}

[HttpPost("link")]
[Authorize]
public async Task<IActionResult> LinkAccount([FromBody] LinkRequest body)
{
    var profile = DecodeLinkingToken(body.Token);
    var user = await _db.Users.FirstAsync(u =>
        u.Email == User.FindFirstValue(ClaimTypes.Email));
    user.Provider = profile.Provider;
    user.ProviderId = profile.ProviderId;
    user.AccessToken = profile.AccessToken;
    await _db.SaveChangesAsync();
    return Ok(new { success = true });
}

Enterprise SSO: SAML and OIDC

ลูกค้าเอนเทอร์ไพรส์มักต้องการ SAML 2.0 หรือ OpenID Connect สำหรับ single sign-on (SSO)

Azure AD / Entra ID Setup

// src/config/passport-azure-ad.ts
import passport from 'passport';
import { BearerStrategy } from 'passport-azure-ad';
import jwt from 'jsonwebtoken';

passport.use(
  'azure-ad',
  new BearerStrategy(
    {
      clientID: process.env.AZURE_CLIENT_ID!,
      audience: process.env.AZURE_CLIENT_ID!,
    },
    async (token: any, done) => {
      try {
        // Token is already validated by BearerStrategy
        const user = await User.findOne({
          'oauth.provider': 'azure-ad',
          'oauth.providerId': token.oid,
        });

        if (!user) {
          return done(null, false, { message: 'User not found' });
        }

        done(null, user, token);
      } catch (error) {
        done(error);
      }
    }
  )
);
// Spring Boot with Azure AD starter
// application.properties:
// spring.cloud.azure.active-directory.enabled=true
// spring.cloud.azure.active-directory.credential.client-id=${AZURE_CLIENT_ID}
// spring.cloud.azure.active-directory.credential.client-secret=${AZURE_CLIENT_SECRET}
// spring.cloud.azure.active-directory.profile.tenant-id=${AZURE_TENANT_ID}

@Configuration
@EnableWebSecurity
public class AzureSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.apply(AadWebApplicationHttpSecurityConfigurer.aadWebApplication())
            .and()
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated());
        return http.build();
    }
}

@Service
public class AzureUserService {
    @Autowired private UserRepository userRepo;

    public User loadFromToken(OidcUser oidcUser) {
        String oid = oidcUser.getClaim("oid");
        return userRepo.findByProviderAndProviderId("azure-ad", oid)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }
}
from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer

azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
    app_client_id=settings.AZURE_CLIENT_ID,
    tenant_id=settings.AZURE_TENANT_ID,
    scopes={f"api://{settings.AZURE_CLIENT_ID}/user_impersonation": "user_impersonation"},
)

@router.get("/protected")
async def protected_route(
    token: dict = Security(azure_scheme),
    db: Session = Depends(get_db),
):
    oid = token.get("oid")
    user = db.query(User).filter_by(provider="azure-ad", provider_id=oid).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return {"user": user}
// Program.cs — Azure AD OIDC
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

// appsettings.json:
// "AzureAd": {
//   "Instance": "https://login.microsoftonline.com/",
//   "TenantId": "YOUR_TENANT_ID",
//   "ClientId": "YOUR_CLIENT_ID",
//   "Audience": "YOUR_CLIENT_ID"
// }

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProtectedController : ControllerBase
{
    private readonly AppDbContext _db;
    public ProtectedController(AppDbContext db) => _db = db;

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var oid = User.FindFirstValue("oid");
        var user = await _db.Users.FirstOrDefaultAsync(u =>
            u.Provider == "azure-ad" && u.ProviderId == oid);
        if (user == null)
            return NotFound(new { message = "User not found" });
        return Ok(new { user });
    }
}

SAML 2.0 with Passport (Okta, Azure AD, others)

import { Strategy as SamlStrategy } from 'passport-saml';

passport.use(
  'saml',
  new SamlStrategy(
    {
      path: '/auth/saml/callback',
      entryPoint: process.env.SAML_ENTRY_POINT!,
      issuer: 'your-app-identifier',
      cert: process.env.SAML_CERT!,
      identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
    },
    async (profile: any, done) => {
      try {
        let user = await User.findOne({
          email: profile.email || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
        });

        if (!user) {
          user = new User({
            email: profile.email,
            name: profile.displayName,
            emailVerified: true,
            oauth: {
              provider: 'saml',
              providerId: profile.nameID,
            },
          });
          await user.save();
        }

        done(null, user);
      } catch (error) {
        done(error);
      }
    }
  )
);

// Routes
app.get('/auth/saml', passport.authenticate('saml', { failureRedirect: '/login' }));

app.post(
  '/auth/saml/callback',
  passport.authenticate('saml', { failureRedirect: '/login' }),
  (req, res) => {
    const sessionToken = generateSessionToken(req.user);
    res.cookie('sessionToken', sessionToken, { /* ... */ });
    res.redirect('/dashboard');
  }
);

// SAML metadata endpoint (for IdP configuration)
app.get('/auth/saml/metadata', (req, res) => {
  res.type('application/xml');
  res.send(new SamlStrategy(/* ... */).generateServiceProviderMetadata());
});
// Spring Security SAML 2.0
// pom.xml: spring-security-saml2-service-provider

@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .saml2Login(saml2 -> saml2
                .relyingPartyRegistrationRepository(relyingPartyRepo())
                .successHandler((req, res, auth) -> res.sendRedirect("/dashboard")))
            .saml2Logout(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public RelyingPartyRegistrationRepository relyingPartyRepo() {
        RelyingPartyRegistration registration = RelyingPartyRegistration
            .withRegistrationId("saml")
            .entityId("your-app-identifier")
            .assertionConsumerServiceLocation("/auth/saml/callback")
            .assertingPartyDetails(party -> party
                .entityId(System.getenv("SAML_IDP_ENTITY_ID"))
                .singleSignOnServiceLocation(System.getenv("SAML_ENTRY_POINT"))
                .verificationX509Credentials(c -> c.add(
                    Saml2X509Credential.verification(loadCert()))))
            .build();
        return new InMemoryRelyingPartyRegistrationRepository(registration);
    }
}

// SAML metadata endpoint — Spring generates automatically at:
// GET /saml2/service-provider-metadata/{registrationId}
# Flask-SAML2 or python3-saml
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from fastapi import Request

def init_saml_auth(req: dict) -> OneLogin_Saml2_Auth:
    return OneLogin_Saml2_Auth(req, custom_base_path="saml/")

@router.get("/auth/saml")
async def saml_login(request: Request):
    saml_req = await prepare_fastapi_request(request)
    auth = init_saml_auth(saml_req)
    return RedirectResponse(auth.login())

@router.post("/auth/saml/callback")
async def saml_callback(request: Request, db: Session = Depends(get_db)):
    saml_req = await prepare_fastapi_request(request)
    auth = init_saml_auth(saml_req)
    auth.process_response()
    if not auth.is_authenticated():
        raise HTTPException(status_code=401, detail="SAML authentication failed")

    attrs = auth.get_attributes()
    email = attrs.get("email", [None])[0] or auth.get_nameid()
    name = attrs.get("displayName", [None])[0]

    user = db.query(User).filter_by(email=email).first()
    if not user:
        user = User(email=email, name=name, email_verified=True,
            provider="saml", provider_id=auth.get_nameid())
        db.add(user)
        db.commit()

    return create_session_response(user)

@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
    saml_req = await prepare_fastapi_request(request)
    auth = init_saml_auth(saml_req)
    metadata = auth.get_settings().get_sp_metadata()
    return Response(content=metadata, media_type="application/xml")
// Program.cs — SAML 2.0 with Sustainsys.Saml2
builder.Services.AddAuthentication()
    .AddSaml2(options => {
        options.SPOptions.EntityId = new EntityId("your-app-identifier");
        options.IdentityProviders.Add(new IdentityProvider(
            new EntityId(Environment.GetEnvironmentVariable("SAML_IDP_ENTITY_ID")),
            options.SPOptions)
        {
            MetadataLocation = Environment.GetEnvironmentVariable("SAML_IDP_METADATA_URL"),
            LoadMetadata = true,
        });
    });

[ApiController]
[Route("auth/saml")]
public class SamlController : ControllerBase
{
    [HttpGet("")]
    public IActionResult SamlLogin() =>
        Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
            Saml2Defaults.Scheme);

    // Callback is handled automatically by Sustainsys.Saml2 middleware
    // at POST /Saml2/Acs

    [HttpGet("metadata")]
    public IActionResult Metadata()
    {
        // Sustainsys exposes metadata at GET /Saml2
        return Redirect("/Saml2");
    }
}

ความปลอดภัย: รายละเอียดการใช้งานที่สำคัญ

1. State Parameter

ใช้ parameter state เสมอเพื่อป้องกันการโจมตี CSRF:

import crypto from 'crypto';

// Generate state before redirect
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;

// Include in authorization URL
const authUrl = `https://provider.com/authorize?client_id=...&state=${state}`;

// Verify on callback
if (req.query.state !== req.session.oauthState) {
  return res.status(400).json({ error: 'State mismatch' });
}
import java.security.SecureRandom;

// Generate state before redirect
SecureRandom sr = new SecureRandom();
byte[] stateBytes = new byte[32];
sr.nextBytes(stateBytes);
String state = HexFormat.of().formatHex(stateBytes);
session.setAttribute("oauthState", state);

// Include in authorization URL
String authUrl = "https://provider.com/authorize?client_id=...&state=" + state;

// Verify on callback
String returnedState = request.getParameter("state");
String savedState = (String) session.getAttribute("oauthState");
if (!MessageDigest.isEqual(returnedState.getBytes(), savedState.getBytes())) {
    return ResponseEntity.status(400).body(Map.of("error", "State mismatch"));
}
import os

# Generate state before redirect
state = os.urandom(32).hex()
request.session["oauth_state"] = state

# Include in authorization URL
auth_url = f"https://provider.com/authorize?client_id=...&state={state}"

# Verify on callback
returned_state = request.query_params.get("state")
saved_state = request.session.get("oauth_state")
if not secrets.compare_digest(returned_state or "", saved_state or ""):
    raise HTTPException(status_code=400, detail="State mismatch")
using System.Security.Cryptography;

// Generate state before redirect
var stateBytes = RandomNumberGenerator.GetBytes(32);
var state = Convert.ToHexString(stateBytes).ToLower();
HttpContext.Session.SetString("OAuthState", state);

// Include in authorization URL
var authUrl = $"https://provider.com/authorize?client_id=...&state={state}";

// Verify on callback
var returnedState = Request.Query["state"].ToString();
var savedState = HttpContext.Session.GetString("OAuthState") ?? "";
if (!CryptographicOperations.FixedTimeEquals(
        System.Text.Encoding.UTF8.GetBytes(returnedState),
        System.Text.Encoding.UTF8.GetBytes(savedState)))
{
    return BadRequest(new { error = "State mismatch" });
}

2. PKCE for Mobile and SPAs

PKCE มีบังคับสำหรับ:

  • Mobile apps
  • Single Page Applications (SPAs)
  • Native desktop apps
// Client-side: Generate and store code verifier
const codeVerifier = base64url(crypto.randomBytes(32));
sessionStorage.setItem('pkce_verifier', codeVerifier);

// Server-side: Verify code challenge
function verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean {
  const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
  return hash === codeChallenge;
}
// Client-side (Android/desktop): Generate and store code verifier
SecureRandom sr = new SecureRandom();
byte[] bytes = new byte[32];
sr.nextBytes(bytes);
String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// Store in secure prefs / session

// Server-side: Verify code challenge
public boolean verifyCodeChallenge(String codeVerifier, String codeChallenge) throws Exception {
    MessageDigest digest = MessageDigest.getInstance("SHA-256");
    byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
    String computed = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
    return MessageDigest.isEqual(computed.getBytes(), codeChallenge.getBytes());
}
import secrets, hashlib, base64

# Client-side: Generate and store code verifier
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
# Store in session

# Server-side: Verify code challenge
def verify_code_challenge(code_verifier: str, code_challenge: str) -> bool:
    digest = hashlib.sha256(code_verifier.encode()).digest()
    computed = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
    return secrets.compare_digest(computed, code_challenge)
// Client-side: Generate and store code verifier
var bytes = RandomNumberGenerator.GetBytes(32);
var codeVerifier = Base64UrlEncode(bytes);
// Store in session / secure storage

// Server-side: Verify code challenge
public static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge)
{
    var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
    var computed = Base64UrlEncode(hash);
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(computed),
        Encoding.UTF8.GetBytes(codeChallenge));
}

3. Redirect URI Validation

ตรวจสอบ redirect URIs เสมอ:

const ALLOWED_REDIRECTS = [
  'https://yourapp.com/auth/callback',
  'https://app.yourapp.com/auth/callback',
];

function validateRedirectUri(redirectUri: string): boolean {
  return ALLOWED_REDIRECTS.includes(redirectUri);
}
private static final Set<String> ALLOWED_REDIRECTS = Set.of(
    "https://yourapp.com/auth/callback",
    "https://app.yourapp.com/auth/callback"
);

public boolean validateRedirectUri(String redirectUri) {
    return ALLOWED_REDIRECTS.contains(redirectUri);
}
ALLOWED_REDIRECTS = {
    "https://yourapp.com/auth/callback",
    "https://app.yourapp.com/auth/callback",
}

def validate_redirect_uri(redirect_uri: str) -> bool:
    return redirect_uri in ALLOWED_REDIRECTS
private static readonly HashSet<string> AllowedRedirects = new()
{
    "https://yourapp.com/auth/callback",
    "https://app.yourapp.com/auth/callback",
};

public static bool ValidateRedirectUri(string redirectUri) =>
    AllowedRedirects.Contains(redirectUri);

ไม่เคยใช้ regex หรือ wildcards เป็นที่ชัดเจน

4. Token Storage

ต้อง:

  • เก็บ access tokens ในหน่วยความจำ (หายไปหลังจาก page refresh แต่ปลอดภัย)
  • เก็บ refresh tokens ใน httpOnly, secure cookies
  • เก็บข้อมูลผู้ใช้ใน Vuex/Redux ไม่ใช่ localStorage

ห้าม:

  • เก็บ access tokens ใน localStorage (เสี่ยงต่อการโจมตี XSS)
  • เก็บ tokens ในคุกกี้ที่ไม่มี httpOnly flag
  • บันทึกหรือส่ง tokens ใน URLs หรือ referer headers
// Secure token storage
export class AuthService {
  private accessToken: string | null = null;

  setAccessToken(token: string) {
    this.accessToken = token;
  }

  getAccessToken(): string | null {
    return this.accessToken;
  }

  // Refresh token in httpOnly cookie (handled by browser automatically)
}
@Service
@RequestScope
public class AuthService {
    // Store access token in request-scoped bean (memory only, never persisted to client)
    private String accessToken;

    public void setAccessToken(String token) {
        this.accessToken = token;
    }

    public String getAccessToken() {
        return accessToken;
    }

    // Refresh token is managed via HttpOnly cookie by the servlet container
}
# Store access token in server-side session (Redis/DB backed), not client storage
class AuthService:
    def __init__(self):
        self._access_token: str | None = None  # In-memory only

    def set_access_token(self, token: str) -> None:
        self._access_token = token

    def get_access_token(self) -> str | None:
        return self._access_token

    # Refresh token is stored in HttpOnly cookie set by the server
// Scoped service — token lives only for the duration of the HTTP request
public class AuthService : IAuthService
{
    private string? _accessToken;

    public void SetAccessToken(string token) => _accessToken = token;

    public string? GetAccessToken() => _accessToken;

    // Refresh token lives in an HttpOnly cookie — never exposed to JavaScript
}

// Program.cs
builder.Services.AddScoped<IAuthService, AuthService>();

5. Token Validation

ตรวจสอบ tokens ที่ฝั่ง server เสมอ:

import jwt from 'jsonwebtoken';

export function validateToken(token: string): any {
  try {
    // Verify signature and expiration
    const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ['HS256'],
      issuer: process.env.TOKEN_ISSUER,
      audience: process.env.TOKEN_AUDIENCE,
    });

    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new Error('Token expired');
    }
    throw new Error('Invalid token');
  }
}
import io.jsonwebtoken.*;

public Claims validateToken(String token) {
    try {
        return Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(
                System.getenv("JWT_SECRET").getBytes(StandardCharsets.UTF_8)))
            .requireIssuer(System.getenv("TOKEN_ISSUER"))
            .requireAudience(System.getenv("TOKEN_AUDIENCE"))
            .build()
            .parseClaimsJws(token)
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new RuntimeException("Token expired");
    } catch (JwtException e) {
        throw new RuntimeException("Invalid token");
    }
}
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError

def validate_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            settings.JWT_SECRET,
            algorithms=["HS256"],
            issuer=settings.TOKEN_ISSUER,
            audience=settings.TOKEN_AUDIENCE,
        )
    except ExpiredSignatureError:
        raise ValueError("Token expired")
    except InvalidTokenError:
        raise ValueError("Invalid token")
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

public ClaimsPrincipal ValidateToken(string token)
{
    var handler = new JwtSecurityTokenHandler();
    try
    {
        return handler.ValidateToken(token, new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
            ValidateIssuer = true,
            ValidIssuer = config["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = config["Jwt:Audience"],
            ClockSkew = TimeSpan.Zero,
        }, out _);
    }
    catch (SecurityTokenExpiredException)
    {
        throw new UnauthorizedAccessException("Token expired");
    }
    catch (SecurityTokenException)
    {
        throw new UnauthorizedAccessException("Invalid token");
    }
}

ปัญหาทั่วไปและวิธีแก้ไข

ปัญหา 1: การเก็บ Access Tokens ใน localStorage

ปัญหา: เสี่ยงต่อการโจมตี XSS

วิธีแก้ไข: ใช้ httpOnly cookies สำหรับ sensitive tokens หน่วยความจำสำหรับ short-lived access tokens

ปัญหา 2: ไม่ใช้ PKCE

ปัญหา: authorization code สามารถถูกดักจับได้บน mobile/SPA

วิธีแก้ไข: ใช้ PKCE เสมอสำหรับ public clients

ปัญหา 3: Hardcoding Redirect URIs

ปัญหา: ไม่ยืดหยุ่น ยากในการจัดการสภาพแวดล้อม

วิธีแก้ไข: ใช้ environment variables

const GOOGLE_CALLBACK_URL = process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback';

ปัญหา 4: การผสมผสาน User Identity กับ Authorization Tokens

ปัญหา: ยากที่จะแยกข้อมูลผู้ใช้จาก API access

วิธีแก้ไข: ใช้ ID tokens สำหรับ identity access tokens สำหรับ APIs

ปัญหา 5: ไม่จัดการกับ Token Expiration

ปัญหา: ผู้ใช้ถูกออกจากระบบโดยไม่มีกลไกการรีเฟรช

วิธีแก้ไข: ใช้งาน automatic token refresh

// Axios interceptor
api.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      const newToken = await refreshAccessToken();
      // Retry request with new token
    }
    return Promise.reject(error);
  }
);
// Spring RestTemplate interceptor
@Bean
public RestTemplate restTemplate() {
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.getInterceptors().add((request, body, execution) -> {
        ClientHttpResponse response = execution.execute(request, body);
        if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
            String newToken = tokenService.refreshAccessToken(
                getCurrentUser().getRefreshToken()).accessToken();
            request.getHeaders().setBearerAuth(newToken);
            response = execution.execute(request, body);
        }
        return response;
    });
    return restTemplate;
}
# httpx event hook
async def refresh_on_401(response: httpx.Response) -> None:
    if response.status_code == 401:
        new_token = await refresh_access_token(current_user.refresh_token)
        response.request.headers["Authorization"] = f"Bearer {new_token}"
        await response.aread()

async_client = httpx.AsyncClient(event_hooks={"response": [refresh_on_401]})
// DelegatingHandler for HttpClient
public class TokenRefreshHandler : DelegatingHandler
{
    private readonly ITokenService _tokens;

    public TokenRefreshHandler(ITokenService tokens) => _tokens = tokens;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var response = await base.SendAsync(request, ct);
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            var newToken = await _tokens.RefreshAccessTokenAsync(
                currentUser.RefreshToken!);
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", newToken.AccessToken);
            response = await base.SendAsync(request, ct);
        }
        return response;
    }
}

ปัญหา 6: OAuth Scope Creep

ปัญหา: การขอ scopes ที่ไม่จำเป็นเสียความเชื่อใจของผู้ใช้

วิธีแก้ไข: ขอ minimal, specific scopes

// Good
scope: ['openid', 'profile', 'email']

// Bad
scope: ['*'] // Not standard, but principle applies

Production Checklist

ก่อนปรับใช้ OAuth ในสภาพแวดล้อมจริง:

  • Environment Variables: ข้อมูลรับรองทั้งหมดใน .env ไม่เคยมี hardcoded
  • HTTPS Only: OAuth ต้องใช้ HTTPS (enforcement ใน Passport config)
  • Secure Cookies: httpOnly: true, secure: true, sameSite: 'lax'
  • PKCE: เปิดใช้งานสำหรับ mobile และ SPA clients ทั้งหมด
  • State Parameter: สร้างและตรวจสอบสำหรับ flows ทั้งหมด
  • Redirect URI Whitelist: เพียง exact matches ไม่มี wildcards
  • Token Validation: Signature และ expiration ตรวจสอบที่ฝั่ง server
  • Token Refresh: Automatic refresh ใช้งานสำหรับ long sessions
  • Rate Limiting: ป้องกัน OAuth endpoints จากการโจมตี brute force
  • CORS Configuration: จำกัดไปยัง allowed origins เท่านั้น
  • Logging: บันทึก auth events (โดยไม่มีข้อมูลที่ละเอียด) สำหรับการแก้ปัญหา
  • Error Handling: อย่าเปิดข้อมูลข้อผิดพลาดโดยละเอียดแก่ผู้ใช้
  • Session Timeout: ใช้งาน idle session timeout (เช่น 30 นาที)
  • Account Linking: จัดการกับผู้ใช้ที่มีอยู่ + OAuth provider ใหม่
  • Revocation: ใช้งาน token revocation endpoint
  • Testing: ทดสอบ token expiration, refresh, revocation scenarios
  • Documentation: เอกสาร supported providers และ setup process

การตรวจสอบและการแก้ปัญหา

Key Metrics to Monitor

// Log OAuth events
export function logAuthEvent(event: string, userId: string, provider: string, status: string) {
  logger.info('auth_event', {
    event,
    userId,
    provider,
    status,
    timestamp: new Date().toISOString(),
  });
}

// Examples
logAuthEvent('login_start', undefined, 'google', 'initiated');
logAuthEvent('login_success', userId, 'google', 'completed');
logAuthEvent('token_refresh', userId, 'github', 'success');
logAuthEvent('token_refresh_failed', userId, 'github', 'failed');
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component
public class AuthEventLogger {
    private static final Logger logger = LoggerFactory.getLogger(AuthEventLogger.class);

    public void logAuthEvent(String event, String userId,
            String provider, String status) {
        logger.info("auth_event event={} userId={} provider={} status={} timestamp={}",
            event, userId, provider, status,
            Instant.now().toString());
    }
}

// Examples
authEventLogger.logAuthEvent("login_start", null, "google", "initiated");
authEventLogger.logAuthEvent("login_success", userId, "google", "completed");
authEventLogger.logAuthEvent("token_refresh", userId, "github", "success");
authEventLogger.logAuthEvent("token_refresh_failed", userId, "github", "failed");
import logging
from datetime import datetime, timezone

logger = logging.getLogger(__name__)

def log_auth_event(event: str, user_id: str | None,
                   provider: str, status: str) -> None:
    logger.info("auth_event", extra={
        "event": event,
        "user_id": user_id,
        "provider": provider,
        "status": status,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })

# Examples
log_auth_event("login_start", None, "google", "initiated")
log_auth_event("login_success", user_id, "google", "completed")
log_auth_event("token_refresh", user_id, "github", "success")
log_auth_event("token_refresh_failed", user_id, "github", "failed")
public class AuthEventLogger
{
    private readonly ILogger<AuthEventLogger> _logger;

    public AuthEventLogger(ILogger<AuthEventLogger> logger) => _logger = logger;

    public void LogAuthEvent(string eventName, string? userId,
        string provider, string status)
    {
        _logger.LogInformation(
            "auth_event {Event} userId={UserId} provider={Provider} " +
            "status={Status} timestamp={Timestamp}",
            eventName, userId, provider, status,
            DateTimeOffset.UtcNow.ToString("o"));
    }
}

// Examples
authLogger.LogAuthEvent("login_start", null, "google", "initiated");
authLogger.LogAuthEvent("login_success", userId, "google", "completed");
authLogger.LogAuthEvent("token_refresh", userId, "github", "success");
authLogger.LogAuthEvent("token_refresh_failed", userId, "github", "failed");

แก้ปัญหาปัญหาทั่วไป

ปัญหา: “redirect_uri_mismatch”

  • ตรวจสอบการกำหนดค่า OAuth provider ตรงกับ callback URL ของคุณ
  • ตรวจสอบว่า environment variable ถูกตั้งค่าอย่างถูกต้อง
  • ตรวจสอบ URL encoding (ช่องว่างควรเป็น %20 ไม่ใช่ +)

ปัญหา: “invalid_grant”

  • Authorization code ถูกใช้แล้วหรือหมดอายุ
  • Code verifier ไม่ตรงกับ code challenge (PKCE)
  • ข้อมูลรับรองไคลเอ็นต์ไม่ถูกต้อง

ปัญหา: ผู้ใช้เข้าสู่ระบบแต่ไม่มีข้อมูลโปรไฟล์

  • ตรวจสอบการกำหนดค่า OAuth scope
  • ตรวจสอบว่าอีเมลเป็นสาธารณะ (GitHub)
  • Fallback ถึงการสร้างผู้ใช้ทั่วไปหากข้อมูลหายไป

บทสรุป

การใช้งาน OAuth 2.0 อาจดูซับซ้อน แต่การแบ่งมันเป็นส่วนประกอบทำให้มันจัดการได้ คุณสมบัติหลักคือ:

  1. เลือกการไหลที่ถูกต้อง (Authorization Code with PKCE สำหรับกรณีส่วนใหญ่)
  2. เข้าใจความแตกต่าง ระหว่าง OAuth (authorization) และ OIDC (authentication)
  3. ใช้งานมาตรการความปลอดภัย (state, PKCE, secure cookies, token validation)
  4. จัดการ edge cases (token expiration, account linking, enterprise SSO)
  5. ตรวจสอบและทดสอบ อย่างรอบคอบก่อน production

ด้วยคู่มือนี้และตัวอย่างโค้ดที่พร้อมใช้งาน คุณมีอาวุธสำหรับการใช้งาน OAuth authentication ที่ปลอดภัยในแอปพลิเคชันใด ๆ

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง