เจาะลึก: 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 userpublic 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_tokenpublic 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 เป็นครั้งแรก คุณต้องตัดสินใจ:
- สร้างบัญชีใหม่ทันที
- ตรวจสอบกับ email ที่มีอยู่
- ต้องการ account linking
Strategy: Auto-link by Email
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 userpublic 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_REDIRECTSprivate 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 อาจดูซับซ้อน แต่การแบ่งมันเป็นส่วนประกอบทำให้มันจัดการได้ คุณสมบัติหลักคือ:
- เลือกการไหลที่ถูกต้อง (Authorization Code with PKCE สำหรับกรณีส่วนใหญ่)
- เข้าใจความแตกต่าง ระหว่าง OAuth (authorization) และ OIDC (authentication)
- ใช้งานมาตรการความปลอดภัย (state, PKCE, secure cookies, token validation)
- จัดการ edge cases (token expiration, account linking, enterprise SSO)
- ตรวจสอบและทดสอบ อย่างรอบคอบก่อน production
ด้วยคู่มือนี้และตัวอย่างโค้ดที่พร้อมใช้งาน คุณมีอาวุธสำหรับการใช้งาน OAuth authentication ที่ปลอดภัยในแอปพลิเคชันใด ๆ