OpenID Connect คืออะไร
OpenID Connect (OIDC) เป็น identity layer ที่สร้างอยู่บน OAuth 2.0 protocol ให้ applications ตรวจสอบตัวตนผู้ใช้ (authentication) และดึงข้อมูลพื้นฐานของผู้ใช้ (profile information) ได้ พัฒนาโดย OpenID Foundation เผยแพร่ครั้งแรกในปี 2014
ความแตกต่างระหว่าง OAuth 2.0 กับ OpenID Connect คือ OAuth 2.0 เป็น authorization protocol อนุญาตให้ app เข้าถึง resources ของผู้ใช้ (เช่น อ่าน Google Drive) แต่ไม่ได้บอกว่าผู้ใช้เป็นใคร OIDC เพิ่ม authentication layer บน OAuth 2.0 ทำให้รู้ว่าผู้ใช้เป็นใคร ผ่าน ID Token (JWT) ที่มีข้อมูลเช่น sub (user ID), email, name
Components หลักของ OIDC ได้แก่ OpenID Provider (OP) เช่น Google, Azure AD, Keycloak ทำหน้าที่ authenticate ผู้ใช้และออก tokens, Relying Party (RP) คือ application ที่ต้องการ authenticate ผู้ใช้, ID Token เป็น JWT ที่มีข้อมูลผู้ใช้, Access Token ใช้เข้าถึง protected resources, UserInfo Endpoint ให้ข้อมูลเพิ่มเติมของผู้ใช้
ติดตั้ง OpenID Connect Provider
Setup Keycloak เป็น OIDC Provider
# === OpenID Connect Provider Setup ===
# 1. Install Keycloak with Docker
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev
ports:
- "8080:8080"
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak_pass
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin_pass
depends_on:
- postgres
postgres:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak_pass
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:
EOF
docker-compose up -d
# 2. Access Keycloak Admin Console
# URL: http://localhost:8080/admin
# Login: admin / admin_pass
# 3. Create Realm
# Keycloak Admin > Create Realm
# Realm Name: myapp
# Enabled: ON
# 4. Create Client (OIDC Application)
# Clients > Create Client
# Client ID: my-web-app
# Client Protocol: openid-connect
# Root URL: http://localhost:3000
# Valid Redirect URIs: http://localhost:3000/callback
# Web Origins: http://localhost:3000
# 5. OIDC Discovery Endpoint
curl -s http://localhost:8080/realms/myapp/.well-known/openid-configuration | python3 -m json.tool
# Output includes:
# - authorization_endpoint
# - token_endpoint
# - userinfo_endpoint
# - jwks_uri
# - supported scopes
# - supported response_types
# - supported grant_types
# 6. Create Test User
# Users > Add User
# Username: testuser
# Email: test@example.com
# First Name: Test
# Last Name: User
# Email Verified: ON
# Credentials > Set Password: test123
# Temporary: OFF
# 7. Test Authentication Flow
# Authorization URL:
# http://localhost:8080/realms/myapp/protocol/openid-connect/auth?
# client_id=my-web-app&
# response_type=code&
# scope=openid profile email&
# redirect_uri=http://localhost:3000/callback&
# state=random_state_value
echo "Keycloak OIDC Provider configured"
Authentication Flows
OIDC Authentication Flows ที่สำคัญ
# === OIDC Authentication Flows ===
# 1. Authorization Code Flow (Recommended for web apps)
# ===================================
# Step 1: Redirect user to authorization endpoint
# GET /authorize?
# response_type=code&
# client_id=my-web-app&
# redirect_uri=http://localhost:3000/callback&
# scope=openid profile email&
# state=xyz123&
# nonce=abc456
# Step 2: User authenticates at OP (login page)
# Step 3: OP redirects back with authorization code
# GET http://localhost:3000/callback?code=AUTH_CODE&state=xyz123
# Step 4: Exchange code for tokens (server-side)
curl -X POST http://localhost:8080/realms/myapp/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "client_id=my-web-app" \
-d "client_secret=CLIENT_SECRET" \
-d "redirect_uri=http://localhost:3000/callback"
# Response:
# {
# "access_token": "eyJhbGciOiJSUzI1NiI...",
# "token_type": "Bearer",
# "expires_in": 300,
# "refresh_token": "eyJhbGciOiJIUzI1NiI...",
# "id_token": "eyJhbGciOiJSUzI1NiI..."
# }
# Step 5: Decode ID Token (JWT)
# Header: {"alg": "RS256", "typ": "JWT", "kid": "key-id"}
# Payload: {
# "iss": "http://localhost:8080/realms/myapp",
# "sub": "user-uuid",
# "aud": "my-web-app",
# "exp": 1705305900,
# "iat": 1705305600,
# "nonce": "abc456",
# "email": "test@example.com",
# "name": "Test User",
# "preferred_username": "testuser"
# }
# 2. Authorization Code Flow with PKCE (SPA / Mobile)
# ===================================
# Generate code_verifier (random 43-128 chars)
# code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
# code_challenge = BASE64URL(SHA256(code_verifier))
# Step 1: Include code_challenge in auth request
# GET /authorize?
# response_type=code&
# client_id=my-spa&
# code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
# code_challenge_method=S256&
# ...
# Step 4: Include code_verifier in token request
# POST /token
# grant_type=authorization_code&
# code=AUTH_CODE&
# code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
# 3. Get UserInfo
curl -H "Authorization: Bearer ACCESS_TOKEN" \
http://localhost:8080/realms/myapp/protocol/openid-connect/userinfo
echo "OIDC flows documented"
Implement OIDC ใน Application
Integrate OIDC กับ web application
#!/usr/bin/env python3
# oidc_client.py — OIDC Client Implementation
import json
import hashlib
import base64
import secrets
import logging
from typing import Dict, Optional
from urllib.parse import urlencode
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("oidc")
class OIDCClient:
def __init__(self, issuer, client_id, client_secret=None, redirect_uri=""):
self.issuer = issuer
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.discovery = None
def discover(self):
"""Fetch OIDC discovery document"""
# In production: requests.get(f"{self.issuer}/.well-known/openid-configuration").json()
self.discovery = {
"issuer": self.issuer,
"authorization_endpoint": f"{self.issuer}/protocol/openid-connect/auth",
"token_endpoint": f"{self.issuer}/protocol/openid-connect/token",
"userinfo_endpoint": f"{self.issuer}/protocol/openid-connect/userinfo",
"jwks_uri": f"{self.issuer}/protocol/openid-connect/certs",
"end_session_endpoint": f"{self.issuer}/protocol/openid-connect/logout",
"scopes_supported": ["openid", "profile", "email", "roles"],
"response_types_supported": ["code", "id_token", "code id_token"],
"grant_types_supported": ["authorization_code", "refresh_token"],
}
return self.discovery
def generate_pkce(self):
"""Generate PKCE code verifier and challenge"""
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
return {"code_verifier": verifier, "code_challenge": challenge}
def build_auth_url(self, scope="openid profile email", pkce=None):
"""Build authorization URL"""
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": scope,
"state": state,
"nonce": nonce,
}
if pkce:
params["code_challenge"] = pkce["code_challenge"]
params["code_challenge_method"] = "S256"
auth_endpoint = self.discovery["authorization_endpoint"]
url = f"{auth_endpoint}?{urlencode(params)}"
return {
"url": url,
"state": state,
"nonce": nonce,
}
def exchange_code(self, code, code_verifier=None):
"""Exchange authorization code for tokens"""
token_request = {
"grant_type": "authorization_code",
"code": code,
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
}
if self.client_secret:
token_request["client_secret"] = self.client_secret
if code_verifier:
token_request["code_verifier"] = code_verifier
# In production: requests.post(discovery["token_endpoint"], data=token_request)
return {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzI1NiJ9...",
"id_token": "eyJhbGciOiJSUzI1NiJ9...",
}
def validate_id_token(self, id_token, nonce):
"""Validate ID token claims"""
# In production: decode JWT, verify signature with JWKS
claims = {
"iss": self.issuer,
"sub": "user-uuid-123",
"aud": self.client_id,
"exp": 1705305900,
"iat": 1705305600,
"nonce": nonce,
"email": "test@example.com",
"name": "Test User",
}
validations = {
"issuer_match": claims["iss"] == self.issuer,
"audience_match": claims["aud"] == self.client_id,
"nonce_match": claims["nonce"] == nonce,
"not_expired": True, # check exp > now
"signature_valid": True, # verify with JWKS
}
return {
"valid": all(validations.values()),
"claims": claims,
"validations": validations,
}
client = OIDCClient(
issuer="http://localhost:8080/realms/myapp",
client_id="my-web-app",
client_secret="secret123",
redirect_uri="http://localhost:3000/callback"
)
discovery = client.discover()
print("Discovery:", json.dumps(list(discovery.keys()), indent=2))
pkce = client.generate_pkce()
auth = client.build_auth_url(pkce=pkce)
print("\nAuth URL:", auth["url"][:100] + "...")
validation = client.validate_id_token("token...", auth["nonce"])
print("\nValidation:", json.dumps(validation["validations"], indent=2))
Security Best Practices
แนวทาง security สำหรับ OIDC
# === OIDC Security Best Practices ===
# 1. Always Use PKCE
# ===================================
# Even for confidential clients (server-side)
# PKCE prevents authorization code interception attacks
# Use S256 method (not plain)
# code_challenge = BASE64URL(SHA256(code_verifier))
# 2. Validate ID Token Properly
# ===================================
# Checklist:
# [ ] Verify JWT signature using JWKS from provider
# [ ] Check iss (issuer) matches expected value
# [ ] Check aud (audience) contains your client_id
# [ ] Check exp (expiration) is in the future
# [ ] Check iat (issued at) is not too far in the past
# [ ] Check nonce matches the one sent in auth request
# [ ] Check azp (authorized party) if present
#
# NEVER trust ID token without full validation
# NEVER decode JWT without verifying signature
# 3. Token Storage
# ===================================
# Server-side (recommended):
# - Store tokens in server session
# - Use HTTP-only, Secure, SameSite cookies for session ID
# - Never expose tokens to client-side JavaScript
#
# SPA (if needed):
# - Use in-memory storage (not localStorage/sessionStorage)
# - Use BFF (Backend for Frontend) pattern
# - Short-lived access tokens (5-15 minutes)
# - Silent refresh with refresh tokens
# 4. State Parameter
# ===================================
# MUST use state parameter to prevent CSRF
# Generate cryptographically random state
# Store in session, verify on callback
# state = secrets.token_urlsafe(32)
# 5. Redirect URI Validation
# ===================================
# Register exact redirect URIs (no wildcards)
# Use HTTPS in production
# Validate redirect_uri on both authorization and token requests
# Never allow open redirects
# 6. Token Lifetime Configuration
# ===================================
# Keycloak settings:
# Realm Settings > Tokens
# Access Token Lifespan: 5 minutes
# Refresh Token Lifespan: 30 minutes
# ID Token Lifespan: 5 minutes
# SSO Session Idle: 30 minutes
# SSO Session Max: 8 hours
# 7. Logout
# ===================================
# Implement both:
# - Local logout (clear session)
# - RP-Initiated Logout (redirect to OP logout endpoint)
# POST /protocol/openid-connect/logout
# id_token_hint=ID_TOKEN&
# post_logout_redirect_uri=http://localhost:3000&
# state=logout_state
# 8. Scope Minimization
# ===================================
# Request only needed scopes
# openid (required)
# profile (name, picture)
# email (email address)
# Don't request: offline_access unless needed
echo "Security best practices documented"
Troubleshooting และ Monitoring
Debug และ monitor OIDC
#!/usr/bin/env python3
# oidc_monitor.py — OIDC Monitoring
import json
import logging
from datetime import datetime
from typing import Dict
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("monitor")
class OIDCMonitor:
def __init__(self):
self.events = []
def track_auth_event(self, event_type, details):
event = {
"timestamp": datetime.utcnow().isoformat(),
"type": event_type,
"details": details,
}
self.events.append(event)
return event
def common_errors(self):
return {
"invalid_client": {
"cause": "Wrong client_id or client_secret",
"fix": "Check Keycloak client configuration, verify credentials",
},
"invalid_grant": {
"cause": "Authorization code expired or already used",
"fix": "Code expires in 60 seconds, ensure single use, check clock sync",
},
"invalid_redirect_uri": {
"cause": "redirect_uri doesn't match registered URIs",
"fix": "Add exact URI in Keycloak client > Valid Redirect URIs",
},
"invalid_scope": {
"cause": "Requested scope not allowed for client",
"fix": "Enable scope in Keycloak client > Client Scopes",
},
"token_expired": {
"cause": "Access token or refresh token expired",
"fix": "Implement token refresh flow, adjust token lifetimes",
},
"signature_verification_failed": {
"cause": "JWT signature doesn't match JWKS",
"fix": "Fetch latest JWKS, check key rotation, verify algorithm",
},
}
def health_check(self):
checks = {
"discovery_endpoint": {"status": "ok", "latency_ms": 15},
"token_endpoint": {"status": "ok", "latency_ms": 45},
"userinfo_endpoint": {"status": "ok", "latency_ms": 25},
"jwks_endpoint": {"status": "ok", "latency_ms": 10},
"certificate_expiry_days": 365,
}
all_ok = all(c.get("status") == "ok" for c in checks.values() if isinstance(c, dict))
return {
"healthy": all_ok,
"checks": checks,
"timestamp": datetime.utcnow().isoformat(),
}
monitor = OIDCMonitor()
errors = monitor.common_errors()
print("Common Errors:", json.dumps(list(errors.keys()), indent=2))
health = monitor.health_check()
print("Health:", json.dumps(health, indent=2))
FAQ คำถามที่พบบ่อย
Q: OpenID Connect กับ SAML ต่างกันอย่างไร?
A: OIDC ใช้ JSON/JWT format เบากว่า ง่ายกว่า เหมาะสำหรับ modern web apps, SPAs, mobile apps ทำงานบน OAuth 2.0 SAML ใช้ XML format หนักกว่า ซับซ้อนกว่า เหมาะสำหรับ enterprise SSO legacy applications OIDC มี discovery endpoint ทำให้ configuration อัตโนมัติ SAML ต้อง exchange metadata manually สำหรับ applications ใหม่แนะนำ OIDC เสมอ SAML สำหรับ legacy enterprise integrations ที่ไม่รองรับ OIDC
Q: OIDC Provider ที่แนะนำมีอะไรบ้าง?
A: Self-hosted Keycloak เป็น open source ที่ popular ที่สุด มี features ครบ SSO, MFA, social login, user federation รองรับ OIDC, SAML, OAuth 2.0 Cloud-based Auth0 (Okta) ง่ายที่สุด มี free tier 7,500 active users, Azure AD (Entra ID) สำหรับ Microsoft ecosystem, Google Identity Platform สำหรับ Google Cloud, AWS Cognito สำหรับ AWS ecosystem สำหรับ startup เริ่มจาก Auth0 free tier สำหรับ enterprise ใช้ Keycloak (self-host) หรือ Azure AD
Q: Authorization Code Flow กับ Implicit Flow ใช้อันไหน?
A: ใช้ Authorization Code Flow เสมอ Implicit Flow ถูก deprecate แล้วใน OAuth 2.1 เพราะ tokens ถูก expose ใน URL fragment (browser history, logs) สำหรับ SPA ใช้ Authorization Code Flow with PKCE ที่ปลอดภัยกว่า code แลกเป็น token ผ่าน backchannel ไม่ expose ใน URL สำหรับ server-side apps ใช้ Authorization Code Flow with client secret + PKCE
Q: Token refresh ทำอย่างไร?
A: เมื่อ access token หมดอายุ (ดูจาก exp claim หรือ expires_in) ใช้ refresh token ขอ access token ใหม่ POST /token กับ grant_type=refresh_token และ refresh_token=xxx ได้ access token ใหม่และ refresh token ใหม่ (rotation) สำหรับ SPA ใช้ silent refresh ผ่าน hidden iframe หรือ service worker ตั้ง timer ก่อน token หมดอายุ 30 วินาทีเพื่อ refresh ล่วงหน้า ถ้า refresh token หมดอายุต้อง redirect user กลับไป login ใหม่
