#สร้าง Express API ที่ควบคุมสิทธิ์ด้วย Keycloak และ Access Token

OAuth 2.0 เป็นกรอบการมอบสิทธิ์ให้แอปพลิเคชันเข้าถึงทรัพยากร เช่น API โดยใช้ Access Token แทนการส่งรหัสผ่านของผู้ใช้ให้ทุกบริการ สำหรับ Workshop นี้ Express ทำหน้าที่ Resource Server และ Keycloak ทำหน้าที่ Authorization Server ผู้เรียกต้องขอ Token จาก Keycloak แล้วส่งให้ Express ตรวจสอบก่อนเข้าถึงข้อมูล

บทความนี้ใช้ Node.js 24, Express 5, jose 6 และ Keycloak 26.8.0 ซึ่งเป็น tag ที่ปรากฏในคู่มือ Docker ของ Keycloak ณ วันที่ตรวจสอบ 13 ตุลาคม 2026 ไม่ได้หมายความว่าเป็นเวอร์ชันที่ติดตั้งหรือทดสอบ Keycloak ในสภาพแวดล้อมของผู้เขียนแล้ว [1][2][3]

เวลาเรียนโดยประมาณ: 60–90 นาที
พื้นฐาน: JavaScript, HTTP, REST API และ Docker Compose
ผลลัพธ์: API สำหรับอ่านและสร้างรายการสั่งซื้อ โดยแยกสิทธิ์ orders:read และ orders:write

#1. OAuth 2.0, OIDC และ JWT ต่างกันอย่างไร

คำ หน้าที่ ตัวอย่างใน Workshop
OAuth 2.0 มอบสิทธิ์เข้าถึงทรัพยากร อนุญาตให้อ่านรายการสั่งซื้อ
OpenID Connect หรือ OIDC เพิ่มการยืนยันตัวตนบน OAuth 2.0 ล็อกอินและรับ ID Token
Access Token หลักฐานสำหรับเรียก API ส่งใน Authorization: Bearer ...
ID Token ให้ Client ตรวจสอบข้อมูลการยืนยันตัวตน ใช้ในขั้นตอนล็อกอิน ไม่ส่งแทน Access Token
JWT รูปแบบ Token ที่มี Claims และลายเซ็น Keycloak ออก JWT Access Token
Scope ขอบเขตสิทธิ์ที่ได้รับ orders:read
JWKS ชุด Public Keys สำหรับตรวจลายเซ็น Express อ่านจาก Keycloak

OAuth 2.0 ไม่ได้กำหนดว่า Access Token ทุกตัวต้องเป็น JWT บางระบบใช้ Opaque Token และต้องตรวจผ่าน Introspection Endpoint ตัวอย่างนี้เลือก JWT ตามการตั้งค่า Keycloak ส่วน OIDC กำหนด ID Token สำหรับ Client ที่ทำการล็อกอิน [4][5]

#2. เลือก Flow ให้เหมาะกับงาน

สถานการณ์ Flow ข้อสังเกต
Backend เรียก Backend, งานอัตโนมัติ Client Credentials Token แทนตัว Client หรือ Service Account
เว็บหรือแอปที่มีผู้ใช้ล็อกอิน Authorization Code + PKCE Token อยู่ในบริบทของผู้ใช้
Browser SPA หรือ Mobile App Authorization Code + PKCE Public Client เก็บ Client Secret ให้เป็นความลับไม่ได้
เว็บที่มี Backend จัดการ Session Authorization Code + PKCE Backend สามารถเป็น Confidential Client ได้

Workshop หลักใช้ Client Credentials เพื่อให้เริ่มทดสอบ API ได้ทันที และมีส่วนต่อยอดสำหรับผู้ใช้ด้วย Authorization Code + PKCE แนวทางความปลอดภัย RFC 9700 ระบุว่าไม่ควรใช้การออก Access Token ผ่าน Implicit Flow และห้ามใช้ Resource Owner Password Credentials Grant [6]

#3. สถาปัตยกรรมและลำดับการทำงาน

sequenceDiagram
    participant C as Backend Client
    participant K as Keycloak
    participant E as Express API
    C->>K: Client Credentials + Scope
    K-->>C: Access Token
    C->>E: GET /api/orders + Bearer Token
    E->>K: อ่าน JWKS เมื่อจำเป็น
    K-->>E: Public Keys
    Note over E: ตรวจลายเซ็น, iss, aud, exp, Scope
    E-->>C: 200 หรือ 401 หรือ 403

jose สามารถตรวจ JWT ด้วย Public Keys จาก Remote JWKS ได้ จึงไม่ต้องแชร์ Private Key ของ Keycloak กับ Express และไม่จำเป็นต้องเรียก Keycloak ทุกครั้งที่ตรวจ Token เพราะตัว Resolver มี Cache [3][7]

#4. เตรียมเครื่องมือ

ติดตั้ง Node.js 24, Docker พร้อม Compose, curl และ jq คำสั่ง Shell ในบทความใช้ Bash เช่น macOS, Linux หรือ WSL

node --version
docker compose version
jq --version
mkdir express-oauth2-workshop
cd express-oauth2-workshop
npm init -y
npm install express@5 jose@6 helmet@8
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env server.js"

เก็บ package-lock.json ไว้เมื่อทำ Workshop เพื่อใช้ npm ci ติดตั้งรุ่นเดิมซ้ำใน CI

ไฟล์ที่ต้องสร้างมี compose.yaml, .env, .gitignore, app.js และ server.js

#5. เริ่ม Keycloak ด้วย Docker Compose

สร้างไฟล์ compose.yaml

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.8.0
    command: ["start-dev"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set password in .env}
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - keycloak_data:/opt/keycloak/data

volumes:
  keycloak_data:

สร้างไฟล์ .env และเปลี่ยนรหัสผ่านตัวอย่างก่อนเริ่ม

KEYCLOAK_ADMIN_PASSWORD=change-this-local-admin-password
PORT=3000
OAUTH_ISSUER=http://localhost:8080/realms/express-demo
OAUTH_AUDIENCE=express-api

สร้าง .gitignore

node_modules/
.env
docker compose up -d
docker compose logs -f keycloak

เมื่อ Keycloak เริ่มสำเร็จ กด Ctrl+C เพื่อออกจากการติดตาม Log แล้วเปิด Admin Console ล็อกอินด้วย admin และรหัสผ่านที่ตั้งไว้

start-dev และ HTTP ในบทความนี้ใช้สำหรับเครื่องเรียนเท่านั้น ระบบจริงต้องใช้ HTTPS, ฐานข้อมูลที่เหมาะกับ Production และการตั้งค่า Hostname/Proxy ที่ถูกต้อง [1]

#6. ตั้งค่า Realm, Client และ Scopes

ชื่อเมนูอาจต่างกันเล็กน้อยระหว่างรุ่น ให้ยึดค่าที่ต้องตั้งเป็นหลัก

#6.1 สร้าง Realm

เลือก Create realm ตั้งชื่อ express-demo และใช้ Realm นี้สำหรับทุกขั้นตอนต่อไป ไม่สร้างแอปพลิเคชันใน Realm master

#6.2 สร้าง Client สำหรับงาน Backend

ไปที่ Clients → Create client และตั้งค่า

รายการ ค่า
Client type OpenID Connect
Client ID workshop-machine
Client authentication On
Standard flow Off
Direct access grants Off
Implicit flow Off
Service accounts roles On

หลังบันทึก เปิดแท็บ Credentials แล้วเก็บ Client Secret ไว้ในฝั่ง Backend เท่านั้น Flow นี้ไม่ได้ให้ผู้ใช้ส่ง Username/Password มาให้ Express

#6.3 สร้าง Scope สำหรับอ่านและเขียน

ไปที่ Client scopes → Create client scope สร้างสองรายการ

Name Protocol Include in token scope Type เมื่อสร้าง
orders:read OpenID Connect On None
orders:write OpenID Connect On None

หากหน้าจอมี Display on consent screen สามารถเปิดและกำหนดคำอธิบายสำหรับผู้ใช้ได้ แต่ Client Credentials ไม่มีผู้ใช้มากด Consent

จากนั้นไปที่ Clients → workshop-machine → Client scopes → Add client scope เพิ่มทั้งสอง Scope เป็น Optional การเรียก Token ต้องระบุชื่อ Scope ที่ต้องการ [8]

#6.4 เพิ่ม Audience ให้ Access Token

สร้าง Client Scope ชื่อ express-api-audience โดยใช้ Protocol OpenID Connect, Type None และปิด Include in token scope เพราะ Scope นี้ทำหน้าที่เติม Audience

ใน Scope นี้ไปที่ Mappers → Configure a new mapper → Audience แล้วตั้งค่า

รายการ ค่า
Name express-api-aud
Included Client Audience เว้นว่าง
Included Custom Audience express-api
Add to access token On
Add to ID token Off

นำ Scope express-api-audience ไปผูกกับ workshop-machine แบบ Default เพื่อให้ Access Token มี aud สำหรับ Express เสมอ ชื่อ Audience เป็นค่าที่เราออกแบบสำหรับ API ไม่จำเป็นต้องเท่ากับ Client ID ของผู้ขอ Token [9]

ขอบเขตการสาธิต: Optional Scope ที่ผูกกับ Client นี้เป็นสิทธิ์ที่ผู้ดูแลอนุญาตให้ Client ขอได้ ไม่ได้บังคับว่าเฉพาะผู้ใช้บางคนจึงขอ orders:write ได้ ระบบจริงต้องออกแบบ Role/Policy และสิทธิ์ระดับข้อมูลเพิ่ม เช่น Client สำหรับอ่านอย่างเดียวไม่ควรถูกผูกกับ Scope เขียน

#7. เขียน Express API

สร้าง app.js

import express from 'express';
import helmet from 'helmet';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const invalidTokenCodes = new Set([
  'ERR_JWT_EXPIRED',
  'ERR_JWT_CLAIM_VALIDATION_FAILED',
  'ERR_JWT_INVALID',
  'ERR_JWS_INVALID',
  'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
  'ERR_JOSE_ALG_NOT_ALLOWED',
  'ERR_JWKS_NO_MATCHING_KEY',
]);

export function createApp({ issuer, audience, jwksUrl }) {
  if (!issuer || !audience) {
    throw new Error('OAUTH_ISSUER and OAUTH_AUDIENCE are required');
  }

  const JWKS = createRemoteJWKSet(
    new URL(jwksUrl ?? `${issuer}/protocol/openid-connect/certs`),
    { timeoutDuration: 5000 }
  );

  const app = express();
  app.disable('x-powered-by');
  app.use(helmet());
  app.use(express.json({ limit: '32kb' }));

  const orders = [
    { id: 1, product: 'Software Engineering Book', quantity: 1 },
  ];

  function rejectToken(res) {
    res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
    return res.status(401).json({ error: 'invalid_token' });
  }

  async function authenticate(req, res, next) {
    const header = req.get('authorization');
    if (!header) {
      res.set('WWW-Authenticate', 'Bearer');
      return res.status(401).json({ error: 'authentication_required' });
    }

    const match = /^Bearer ([A-Za-z0-9._~-]+)$/i.exec(header);
    if (!match) return rejectToken(res);

    try {
      const { payload } = await jwtVerify(match[1], JWKS, {
        issuer,
        audience,
        algorithms: ['RS256'],
        requiredClaims: ['iss', 'aud', 'sub', 'iat', 'exp'],
        clockTolerance: 5,
      });

      // Keycloak-specific access-token marker, not a universal OAuth rule.
      if (payload.typ !== 'Bearer') return rejectToken(res);

      req.auth = payload;
      return next();
    } catch (error) {
      if (invalidTokenCodes.has(error.code)) return rejectToken(res);

      // Unknown verification / JWKS failures fail closed.
      console.error('Token verification unavailable:', error.code ?? error.name);
      return res.status(503).json({ error: 'token_verification_unavailable' });
    }
  }

  function requireScope(scope) {
    return (req, res, next) => {
      const scopes = new Set(
        typeof req.auth.scope === 'string'
          ? req.auth.scope.split(/\s+/).filter(Boolean)
          : []
      );
      if (!scopes.has(scope)) {
        res.set(
          'WWW-Authenticate',
          `Bearer error="insufficient_scope", scope="${scope}"`
        );
        return res.status(403).json({ error: 'insufficient_scope' });
      }
      return next();
    };
  }

  app.get('/health', (req, res) => res.json({ status: 'ok' }));

  app.get('/api/orders', authenticate, requireScope('orders:read'),
    (req, res) => res.json({ data: orders })
  );

  app.post('/api/orders', authenticate, requireScope('orders:write'),
    (req, res) => {
      const { product, quantity } = req.body ?? {};
      if (
        typeof product !== 'string' || !product.trim() ||
        product.trim().length > 120 ||
        !Number.isInteger(quantity) || quantity < 1 || quantity > 1000
      ) {
        return res.status(400).json({ error: 'invalid_order' });
      }
      const order = {
        id: orders.length + 1, product: product.trim(), quantity,
      };
      orders.push(order);
      return res.status(201).json({ data: order });
    }
  );

  app.use((err, req, res, next) => {
    const status = err.type === 'entity.too.large' ? 413
      : err.type === 'entity.parse.failed' ? 400 : 500;
    res.status(status).json({ error: status === 500
      ? 'internal_error' : 'invalid_request' });
  });

  return app;
}

สร้าง server.js

import { createApp } from './app.js';

const app = createApp({
  issuer: process.env.OAUTH_ISSUER,
  audience: process.env.OAUTH_AUDIENCE,
});

app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1', () => {
  console.log('Express API ready on http://localhost:3000');
});
npm start

โค้ดผูก API ไว้กับ Loopback สำหรับทดลองบนเครื่องเดียวกับ Keycloak หากนำไปใส่ Container ต้องปรับ Bind Address และ URL ให้ตรงกับการออกแบบเครือข่าย

#ทำไมต้องตรวจหลายค่า

สิ่งที่ตรวจ เหตุผล
Signature ตรวจว่า Token ลงนามด้วย Key ที่เชื่อถือ
iss ต้องมาจาก Realm ที่กำหนด
aud ต้องออกสำหรับ API นี้
exp และ nbf ถ้ามี ตรวจช่วงเวลาที่ Token ใช้ได้
alg จำกัดเป็น RS256 ตามการตั้งค่า Workshop
typ ใน Payload ยอมรับ Keycloak Access Token ชนิด Bearer
scope ตรวจสิทธิ์ของ Endpoint หลังตรวจ Token

การ Decode JWT อย่างเดียวไม่ยืนยันลายเซ็นและห้ามใช้ตัดสินสิทธิ์ ส่วนค่าประเภท typ ใน Payload เป็นเงื่อนไขเฉพาะ Keycloak ในบทความนี้ ผู้ให้บริการรายอื่นอาจใช้รูปแบบต่างกัน ต้องปรับตามเอกสารของผู้ให้บริการ [3]

401 ใช้เมื่อไม่มีหลักฐานการยืนยันตัวตนที่ใช้ได้ ส่วน Token ที่ถูกต้องแต่ขาด Scope ใช้ 403 พร้อม insufficient_scope ตาม Bearer Token specification [5]

#8. ขอ Token แบบ Client Credentials

เปิด Terminal อีกหน้าต่าง แล้วรับ Client Secret แบบไม่แสดงบนหน้าจอ

read -r -s -p 'Client secret: ' CLIENT_SECRET
printf '\n'
TOKEN_ENDPOINT='http://localhost:8080/realms/express-demo/protocol/openid-connect/token'

TOKEN_READ=$(curl --fail-with-body -sS "$TOKEN_ENDPOINT" \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'client_id=workshop-machine' \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode 'scope=orders:read' | jq -er '.access_token')

หากคำสั่งล้มเหลว ให้ตรวจ JSON Error ก่อนทำต่อ ห้ามใช้ค่าว่างหรือ null แทน Token ตัวอย่างนี้เหมาะกับเครื่องเรียน; Production ควรใช้ Secret Manager และ Client Authentication ที่เหมาะสม เพราะค่าใน Process Arguments อาจถูกตรวจดูได้บนเครื่องเดียวกัน

ทดสอบอ่านข้อมูล

curl -i http://localhost:3000/api/orders \
  -H "Authorization: Bearer $TOKEN_READ"

ผลที่คาดหวัง: 200 OK และรายการสั่งซื้อ

ลองเขียนด้วย Token ที่อ่านได้อย่างเดียว

curl -i -X POST http://localhost:3000/api/orders \
  -H "Authorization: Bearer $TOKEN_READ" \
  -H 'Content-Type: application/json' \
  -d '{"product":"OAuth Workshop","quantity":2}'

ผลที่คาดหวัง: 403 Forbidden และ insufficient_scope

ขอ Token ที่มีสิทธิ์เขียน

TOKEN_WRITE=$(curl --fail-with-body -sS "$TOKEN_ENDPOINT" \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'client_id=workshop-machine' \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode 'scope=orders:read orders:write' | jq -er '.access_token')

curl -i -X POST http://localhost:3000/api/orders \
  -H "Authorization: Bearer $TOKEN_WRITE" \
  -H 'Content-Type: application/json' \
  -d '{"product":"OAuth Workshop","quantity":2}'

ผลที่คาดหวัง: 201 Created

#9. ตารางทดสอบ API

กรณีทดสอบ ผลที่คาดหวัง
GET /health ไม่ส่ง Token 200
GET /api/orders ไม่ส่ง Token 401
ส่ง Token รูปแบบผิดหรือแก้ลายเซ็น 401
Token หมดอายุเกิน Clock Tolerance 5 วินาที 401
Token จาก Issuer อื่น 401
Token มี Audience อื่น 401
อ่านด้วย orders:read 200
เขียนด้วย Token ที่มีเฉพาะ orders:read 403
เขียนด้วย orders:write และข้อมูลถูกต้อง 201
เขียนด้วยสิทธิ์ถูกต้อง แต่ quantity เป็น 0 400
ส่ง JSON ไม่ถูกต้อง 400
ตัวตรวจ Token ดึง JWKS ไม่ได้และไม่มี Key ใน Cache 503

ตัวอย่างกรณีไม่ส่ง Token และ Token ผิดรูปแบบ

curl -i http://localhost:3000/api/orders
curl -i http://localhost:3000/api/orders -H 'Authorization: Bearer invalid.token'

Token ที่ถูกแก้ไขต้องถูกปฏิเสธก่อน Route Handler แม้ว่าจะอ้าง Scope ครบก็ตาม

#10. ต่อด้วยผู้ใช้ล็อกอิน: Authorization Code + PKCE

เมื่อมีผู้ใช้ ให้สร้าง Public Client แยกจาก workshop-machine เช่น workshop-user และทดสอบด้วยเครื่องมือที่รองรับ Authorization Code + PKCE เช่น Postman Desktop

#10.1 ตั้งค่า Keycloak

รายการ ค่า
Client ID workshop-user
Client authentication Off
Standard flow On
Direct access grants / Implicit flow Off
PKCE Code Challenge Method S256
Valid redirect URIs Callback URL ที่เครื่องมือแสดงจริง แบบตรงตัว

ผูก express-api-audience แบบ Default และผูก orders:read แบบ Optional ก่อน ถ้าแอปไม่จำเป็นต้องเขียน อย่าผูก orders:write จากนั้นสร้างผู้ใช้ใน Realm express-demo และกำหนดรหัสผ่านสำหรับทดลอง

#10.2 ตั้งค่า OAuth ในเครื่องมือ

รายการ ค่า
Grant Type Authorization Code (With PKCE)
Auth URL http://localhost:8080/realms/express-demo/protocol/openid-connect/auth
Access Token URL http://localhost:8080/realms/express-demo/protocol/openid-connect/token
Client ID workshop-user
Client Secret เว้นว่าง
Scope openid orders:read
Code Challenge Method SHA-256 / S256
Callback URL ค่าเดียวกับ Redirect URI ที่ลงทะเบียน

เครื่องมือต้องสร้าง code_verifier แบบสุ่ม, ส่ง code_challenge และจัดการตรวจ state ของรายการล็อกอิน อย่าตั้ง Code Verifier คงที่หรือใช้ Wildcard Redirect URI เพื่อแก้ปัญหาการตั้งค่า [6]

#10.3 ลำดับการทำงาน

  1. Client สร้าง Code Verifier และ Code Challenge พร้อมค่า State ที่ผูกกับรายการล็อกอิน
  2. Browser ไปยัง Authorization Endpoint โดยส่ง response_type=code และ code_challenge_method=S256
  3. ผู้ใช้ล็อกอินที่ Keycloak
  4. Keycloak ส่ง Authorization Code กลับ Callback URL
  5. Client ตรวจ Callback/State แล้วนำ Code กับ Code Verifier ไปแลก Token
  6. Client ใช้ Access Token เรียก Express API ส่วน ID Token ใช้ในบริบทของ Client
  7. Express ตรวจ Token และ Scope ด้วย Middleware เดิม

สำหรับแอปจริงควรใช้ OIDC Client Library ที่ดูแลการตรวจ ID Token, Issuer, State และ Nonce เมื่อใช้ แทนการเขียนขั้นตอนล็อกอินเอง หากใช้ Backend for Frontend สามารถเก็บ Token ฝั่ง Server และให้ Browser ใช้ Session Cookie ที่มี HttpOnly, Secure, SameSite พร้อมมาตรการ CSRF ที่เหมาะสม

#11. ข้อควรออกแบบก่อนใช้จริง

  • สิทธิ์ระดับข้อมูล: orders:read ไม่ได้ยืนยันว่าผู้ใช้มีสิทธิ์ดู Order ทุกคน ต้องตรวจ Owner/Tenant หรือ Policy ด้วย
  • การจัดเก็บ: Orders ในตัวอย่างอยู่ในหน่วยความจำ Restart แล้วหาย เปลี่ยนเป็น Database เมื่อนำไปใช้งาน
  • Token Lifetime: ใช้ Access Token อายุสั้น การตรวจ JWT ในเครื่องอย่างเดียวอาจยังยอมรับ Token จนหมดอายุหลังผู้ใช้ออกจากระบบหรือสิทธิ์ถูกเปลี่ยน ถ้าต้องเพิกถอนทันทีต้องออกแบบเพิ่มเติม
  • Secret: อย่าใส่ Client Secret ใน SPA, Mobile App, Git หรือ Log ไม่บันทึก Token ทั้งตัว
  • Transport: ระบบจริงใช้ HTTPS ทั้งการขอ Token และเรียก API; ถ้าใช้ Proxy ให้กำหนด Trusted Proxy ตาม Deployment จริง
  • JWT Validation: ไม่เลือก Issuer/JWKS URL ตามข้อมูลที่ยังไม่ได้ตรวจใน Token และไม่ยอมรับ Algorithm ตามที่ Token ร้องขอเอง
  • Browser: CORS ควบคุม Browser Origin แต่ไม่แทน Authorization; แยกการจัดการ Token และ CSRF ให้เหมาะกับสถาปัตยกรรม
  • Operations: จำกัดอัตราการเรียก, บันทึกเหตุการณ์สำคัญแบบไม่เปิดเผย Token และอัปเดต Dependencies

#12. แก้ปัญหาที่พบบ่อย

อาการ ตรวจอะไร
Token Endpoint ตอบ invalid_client Client ID/Secret และ Client authentication
unauthorized_client เปิด Service accounts roles สำหรับ Machine Client แล้วหรือไม่
มี Token แต่ API ตอบ 401 iss, aud, exp, Algorithm และชนิด Token
มี Token แต่ API ตอบ 403 เปิด Include in token scope, ผูก Scope และส่งชื่อ Scope ตอนขอ Token
API ตอบ 503 Keycloak พร้อมใช้งานหรือไม่, JWKS URL และ Network
PKCE ตอบ invalid_grant Code หมดอายุ/ถูกใช้ซ้ำ, Redirect URI และ Code Verifier
เปลี่ยนค่าจาก localhost เป็น 127.0.0.1 แล้ว 401 Issuer ต้องตรงกัน รวม Scheme/Hostname/Port/Realm

ถ้าใช้ Container สำหรับ Express ค่า localhost ภายใน Container จะไม่ใช่เครื่อง Host ต้องวางแผน Hostname ที่ทั้ง Client และ Resource Server เข้าถึงได้ โดยรักษา Issuer ให้สอดคล้องกัน

#13. แบบฝึกหัดต่อยอด

  1. สร้าง Client สำหรับอ่านอย่างเดียว โดยไม่ผูก orders:write แล้วตรวจว่าไม่สามารถขอสิทธิ์เขียนได้
  2. เพิ่ม PostgreSQL และ Endpoint GET /api/orders/:id พร้อมตรวจ Ownership
  3. เพิ่ม Integration Tests สำหรับ Token หมดอายุ, Audience ผิด, ลายเซ็นผิด และ Scope ขาด
  4. เชื่อม Frontend ด้วย Authorization Code + PKCE โดยใช้ OIDC Library

#14. หยุด Workshop

unset CLIENT_SECRET TOKEN_READ TOKEN_WRITE TOKEN_ENDPOINT
docker compose down

คำสั่งนี้หยุด Keycloak และเก็บ Volume ไว้ ใช้ Ctrl+C หยุด Express หากต้องการเริ่ม Realm ใหม่ทั้งหมดจึงค่อยลบ Volume โดยตระหนักว่าการตั้งค่าและผู้ใช้ของ Workshop จะถูกลบ

#15. สรุป

Express รับผิดชอบการตรวจ Access Token และบังคับสิทธิ์ของ API ส่วน Keycloak รับผิดชอบการออก Token และการยืนยันตัวตน การเลือก Flow ให้เหมาะกับผู้เรียกและตรวจทั้ง Signature, Issuer, Audience, เวลา และ Scope ทำให้ระบบควบคุมสิทธิ์ได้ชัดเจน เมื่อมีผู้ใช้ให้ต่อด้วย OIDC และ Authorization Code + PKCE พร้อมสิทธิ์ระดับข้อมูล

#แหล่งอ้างอิง

ตรวจสอบเอกสารวันที่ 13 ตุลาคม 2026 โค้ดและรูปแบบ Workshop เรียบเรียงขึ้นใหม่จากแนวคิดในเอกสารทางการ

  1. Keycloak: Getting started with Docker
  2. Express: Migrating to Express 5
  3. jose: JWT verification and supported versions
  4. OpenID Connect Core 1.0
  5. RFC 6750: OAuth 2.0 Bearer Token Usage
  6. RFC 9700: Best Current Practice for OAuth 2.0 Security
  7. jose: createRemoteJWKSet
  8. Keycloak: Client scopes
  9. Keycloak Server Administration Guide: Audience support and service accounts

#สถานะการตรวจสอบตัวอย่าง

ตรวจ Syntax และทดสอบ Express Middleware ผ่าน 18 กรณีด้วย JWT ที่ลงนามจริงและ JWKS จำลองในเครื่อง ครอบคลุม Token ถูกต้อง, Token หมดอายุ, Issuer/Audience ผิด, ลายเซ็นผิด, Algorithm ผิด, ชนิด Token ผิด, สิทธิ์ไม่พอ, ข้อมูลและ JSON ไม่ถูกต้อง และ JWKS ใช้งานไม่ได้ สภาพแวดล้อมจัดทำบทความไม่มี Docker จึงยังไม่ได้ทดสอบขั้นตอน Keycloak และ PKCE แบบ End-to-End