SiamCafe · Blog
OpenID Connect รู้จัก OIDC ฉบับครบถ้วน 2026
บทความ

OpenID Connect รู้จัก OIDC ฉบับครบถ้วน 2026

เผยแพร่ 28 พฤษภาคม 2569

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 ใหม่