Skip to content

บทที่ 7 — OAuth2 (Login with Google/GitHub + Resource Server — server ที่เก็บข้อมูลและเช็ค token ที่ client ส่งมา)

← บทที่ 6 | สารบัญ | บทที่ 8 →

บทที่ 3 เรียน JWT แบบเขียนเอง — เหมาะกับ app ที่ user ลงทะเบียนกับเราตรง ๆ
บทนี้เรียน OAuth2 — มาตรฐานสำหรับ:

  • "Login with Google / Facebook / GitHub" (Authorization Code flow — ลำดับขั้นตอนการขอ code ก่อน แล้วแลก token)
  • API-to-API auth (Client Credentials flow — ลำดับสำหรับ server-to-server ไม่มี user เกี่ยวข้อง)
  • Resource Server ที่ verify JWT จาก provider อื่น

หลังจบบท คุณจะ:

  • เข้าใจ OAuth2 flow ที่ใช้จริง 3 แบบ + flow ที่ deprecated อีก 2 แบบ (Implicit, Password Grant)
  • ทำ "Login with Google" ใน Spring Boot
  • ทำ Resource Server ที่ verify token จาก Keycloak/Auth0
  • เข้าใจ OIDC + JWT (token อ่านข้างในได้) vs Opaque token (token ทึบ — อ่านข้างในไม่ได้)

🚧 โซนขั้นสูง — ข้ามได้

บทนี้เป็น บทอ้างอิงระดับลึก ถ้าคุณเพิ่งเริ่มและยังไม่ต้องทำ "Login ด้วย Google/Facebook" หรือเชื่อมระบบหลายตัวเข้าด้วยกัน ข้ามไปก่อนได้JWT แบบเขียนเองในบทที่ 3 เพียงพอสำหรับแอปที่ user สมัครกับเราตรง ๆ กลับมาอ่านบทนี้เมื่อต้องต่อกับ identity provider ภายนอก

อ่านบทนี้เมื่อ:

  1. ต้องการปุ่ม "Login with Google / Facebook / LINE"
  2. ต้องการให้หลาย service ใช้ token เดียวกัน (Single Sign-On)
  3. ใช้ Keycloak / Auth0 / Okta แทนระบบ auth ของตัวเอง

📋 ก่อนเริ่ม + ศัพท์ปูพื้น

  • ✅ ต้องผ่าน: Spring Boot บทที่ 3 (Security + JWT)

🔑 ศัพท์หลัก (ต้องรู้ก่อน):

  • Lombok (ลอมบ็อก) = library ที่ generate getter/setter/constructor ให้อัตโนมัติ ใช้บ่อยในบทนี้ (@Getter, @Setter, @AllArgsConstructor) — อ่านที่มาได้ที่ บทที่ 1 § เครื่องมือ
  • OAuth2 (โอ-ออธ-ทู) = มาตรฐานการ "ขออนุญาตเข้าถึงข้อมูล" โดยไม่ต้องบอก password ให้ app อื่น
  • scope (สโคป) = ขอบเขตสิทธิ์ที่ขอ (เช่น อ่าน email ได้ แต่โพสต์ไม่ได้)
  • OIDC (โอ-ไอ-ดี-ซี, OpenID Connect) = ชั้นที่ต่อยอด OAuth2 ให้ "รู้ว่า user เป็นใคร" (login) — ใช้ในหัวข้อบทนี้และ Section 4

📚 ศัพท์รอง (อ่านผ่าน — กลับมาดูได้):

  • provider / IdP (Identity Provider — ผู้ให้บริการพิสูจน์ตัวตน) = เจ้าของระบบ login เช่น Google, Keycloak
  • flow (โฟลว์ — ลำดับขั้นตอน) = ลำดับขั้นตอนการแลก token แต่ละแบบ

1. ทำไมต้องมี OAuth2

สมัยก่อนถ้า app ต้องการ data ของคุณบน Facebook:

text
"กรอก username/password Facebook ของคุณที่นี่"

อันตราย:

  • App รู้ password คุณ — เปลี่ยน password ก็ไม่ได้
  • ใช้ scope ครบทุกอย่าง — ไม่จำกัดสิทธิ์

OAuth2 = "User อนุญาตให้ app อ่าน data ของฉันบน Facebook โดยไม่ต้องให้ password"


2. คำศัพท์ OAuth2

คำคำไทยตัวอย่าง
Resource Ownerเจ้าของข้อมูลUser (คุณ)
Clientแอปของเรา (ผู้ขอเข้าถึง)App ของเรา (เช่น Spotify)
Resource Serverserver ที่เก็บข้อมูลAPI ที่เก็บ data (Facebook API)
Authorization Serverserver ที่ออก tokenFacebook Login, Google OAuth
Access Tokenบัตรชั่วคราวใช้เรียก resourceบัตรชั่วคราว — ใช้เรียก resource
Refresh Tokenบัตรขอบัตรใหม่ (อายุยาว)บัตร "ขอบัตรใหม่" — อายุยาวกว่า
Scopeขอบเขตสิทธิ์ที่ขอสิทธิ์ที่ขอ (read:photos, write:posts)

ใน app เล็ก ๆ Authorization Server + Resource Server อาจเป็น server เดียวกัน


3. OAuth2 Flow 4 แบบ

A. Authorization Code (Web app + Backend)

ใช้เมื่อ: ทำ "Login with Google" ใน Spring Boot

Secret อยู่ฝั่ง backend → ปลอดภัย

B. Authorization Code + PKCE (SPA — Single-Page App / Mobile)

PKCE (พิก-ซี — Proof Key for Code Exchange — "กุญแจพิสูจน์ตอนแลก code")

เหมาะกับ SPA (Single-Page App — เว็บที่ render ฝั่ง browser ทั้งหมด) หรือ mobile app ที่ไม่มี backend คอยเก็บ secret ให้

app แบบนี้ ไม่สามารถเก็บ client_secret ได้อย่างปลอดภัย เพราะโค้ดทั้งหมดรันอยู่ใน browser หรือมือถือของ user เอง — ใครก็ตามที่เปิด dev tools หรือ decompile แอปก็เห็น secret ได้ทันที

แทนที่จะใช้ client_secret → client สุ่ม code_verifier (กุญแจที่สุ่มขึ้นเอง) + คำนวณ code_challenge (ผลแฮชของกุญแจ) เพื่อพิสูจน์ตอนแลก code

  • SHA256 = สูตรคำนวณที่แปลงข้อความยาวเป็นรหัสสั้นที่ตายตัวและย้อนกลับไม่ได้ — ใช้เทียบว่าค่าตรงกันโดยไม่ต้องส่งค่าจริง (ต่อให้ใครดักฟังก็เอา code_verifier ตัวจริงกลับไม่ได้)
  • BASE64URL = วิธีเข้ารหัสผลลัพธ์ให้เป็นตัวอักษรที่ปลอดภัยสำหรับใส่ใน URL (ไม่มีอักขระพิเศษที่ URL ตีความผิด)
text
code_verifier  = random string ยาว 43-128 ตัว
code_challenge = BASE64URL(SHA256(code_verifier))

ขั้นแรก: ส่ง code_challenge ไปพร้อม authorize request
ขั้นแลก: ส่ง code_verifier ตัวจริง — server ตรวจสอบว่า hash ตรงกัน

📎 ดู บท 3 § 13.7 สำหรับ flow ละเอียดและตัวอย่าง code_verifier/code_challenge

ปี 2026 = standard ของ frontend OAuth

C. Client Credentials (Server-to-Server)

ใช้เมื่อ: Service A เรียก Service B — ไม่มี user เกี่ยวข้อง

text
1. Service A → POST /token (client_id + client_secret + grant_type=client_credentials)
2. ได้ access_token
3. ใช้เรียก Service B

D. Implicit / Password Grant — ❌ deprecated

Implicit flow คือ token ออกมาตรง ๆ โดยไม่มีขั้นแลก code (ไม่ปลอดภัยเพราะ token หลุดผ่าน URL ได้) ส่วน Resource Owner Password Credentials (ROPC) คือผู้ใช้กรอก password ให้ app ตรง ๆ (ขัดกับหลักการ OAuth ที่ไม่ควรให้ app รู้ password)

OAuth 2.1 ลบ Implicit + ROPC ออกจากมาตรฐาน — Security Best Current Practice (BCP) ก็ deprecate flow เหล่านี้ไปแล้ว ไม่ควรใช้ในระบบใหม่ (ตรวจสอบสถานะล่าสุดของ OAuth 2.1 ได้ที่ oauth.net/2.1)


4. OAuth2 vs OIDC

OAuth2OIDC
ใช้ทำอะไรAuthorization (ขอสิทธิ์/อนุญาต)Authentication (พิสูจน์ตัวตน) + Authorization (ขอสิทธิ์)
TokenAccess TokenAccess Token + ID Token (JWT ที่เก็บข้อมูล user เช่น email/name)
ตัวอย่าง"อนุญาตให้ app เข้าถึง photos""Login with Google"

OIDC = OAuth2 + identity layer

ID Token เป็น JWT มี info ของ user:

json
{
    "iss": "https://accounts.google.com",
    "sub": "1234567890",
    "email": "anna@gmail.com",
    "name": "Anna",
    "picture": "https://...",
    "exp": 1747562400
}

Part 2: Login with Google (OAuth2 Client)

5. Setup

จะทำ "Login with Google" ต้องเพิ่ม starter 2 ตัว

  • oauth2-client — ทำหน้าที่เป็น OAuth client ไปคุยกับ provider (Google/GitHub/...)
  • security — ฐาน Spring Security ที่ทุกอย่างวางอยู่บน

หลังใส่ 2 ตัวนี้ Spring จะจัดการ flow ของ OAuth ให้เกือบทั้งหมดโดยอัตโนมัติ เหลือแค่ตั้งค่า:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

6. ลงทะเบียน OAuth Client กับ Google

ก่อนใช้ ต้องลงทะเบียน app กับ provider เพื่อให้ได้ client_id + client_secret — ขั้นตอนสำคัญคือตั้ง "redirect URI" ให้ตรงกับที่ Spring ใช้ (/login/oauth2/code/google) ไม่งั้น Google จะไม่ยอม redirect กลับ:

  1. ไป https://console.cloud.google.com → สร้าง project ใหม่ (ถ้ายังไม่มี)
  2. APIs & Services → OAuth consent screen → เลือก External → กรอกชื่อแอป (หน้าตา UI อาจเปลี่ยนได้ตาม Google update)
  3. APIs & Services → Credentials → Create OAuth client ID → Web application
  4. Authorized redirect URIs: http://localhost:8080/login/oauth2/code/google

    path นี้เป็น endpoint ที่ Spring Boot สร้างให้อัตโนมัติ จาก spring-boot-starter-oauth2-client ไม่ต้องเขียน controller เอง

  5. ได้ client_id + client_secret

7. Configuration

ตั้งค่า OAuth client ใน application.yml — ใส่ client-id/secret + scope ที่ต้องการ (openid/profile/email) ข้อดีคือ Spring รู้จัก provider ดัง ๆ (Google/GitHub) อยู่แล้ว ไม่ต้องระบุ endpoint URL เอง แค่ใส่ credential ก็พอ:

yaml
# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:
              - openid
              - profile
              - email
          github:                              # ⚠️ ถ้ายังไม่ได้ลงทะเบียน GitHub ให้ลบ block นี้ออก
            client-id: ${GITHUB_CLIENT_ID}    # ถ้า env var นี้ไม่ได้ตั้ง แอปจะ start ไม่ขึ้น
            client-secret: ${GITHUB_CLIENT_SECRET}
            # ลงทะเบียนได้ที่: GitHub Settings → Developer settings → OAuth Apps
            scope:
              - user:email
              - read:user

${GOOGLE_CLIENT_ID} = อ่านค่าจาก environment variable (ตัวแปรสิ่งแวดล้อม) — อย่า hardcode ลงใน file โดยตรง ตัวอย่างตั้งค่าก่อนรัน: PowerShell $env:GOOGLE_CLIENT_ID="xxx" หรือตั้งใน IDE run configuration

Google/GitHub provider มี default provider config ใน Spring — ไม่ต้องระบุ endpoint เอง


8. SecurityConfig

java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/error", "/webjars/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth -> oauth
                .defaultSuccessUrl("/dashboard", true)
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
            );
        return http.build();
    }
}

ทันทีที่ user เข้า /dashboard (หรือ url อื่นที่ต้อง auth) → redirect ไป /oauth2/authorization/google → Google login → callback → ได้ session

path /oauth2/authorization/google เป็น default endpoint ที่ Spring Security สร้างให้อัตโนมัติ จาก oauth2Login() ตามชื่อ registration ใน yaml (google) — ไม่ต้องเขียน controller เอง

📝 SecurityConfig นี้เป็นเวอร์ชันเริ่มต้น — ใน §10 เราจะอัปเดตให้รวม CustomOidcUserService ด้วย


9. ดึง User Info

หลัง login สำเร็จ Spring เก็บข้อมูล user จาก provider ไว้ให้ — ดึงผ่าน @AuthenticationPrincipal OAuth2User (หรือ OidcUser สำหรับ OIDC) ที่มี attribute เช่น name/email/picture เอาไปแสดงหรือ map เข้า user ในระบบเราต่อ:

java
@RestController
public class UserController {

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal OAuth2User user) {
        return Map.of(
            "name", user.getAttribute("name"),
            "email", user.getAttribute("email"),
            "picture", user.getAttribute("picture")
        );
    }

    @GetMapping("/oidc-info")
    public Map<String, Object> oidcInfo(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
            "sub", user.getSubject(),
            "email", user.getEmail(),
            "name", user.getFullName(),
            "idToken", user.getIdToken().getTokenValue()
        );
    }
}

OAuth2User = generic, OidcUser = มี method สำเร็จรูป (getEmail(), getSubject())


10. บันทึก User ลง DB ครั้งแรก

OAuth2 ยืนยันตัวตนให้แต่ไม่บันทึก user ลง DB ของเราให้ — ต้อง hook เองโดย extend OidcUserService แล้วเช็ค: ถ้า email นี้ยังไม่มีในระบบก็สร้างใหม่ (provisioning ครั้งแรก — การสร้าง account ในระบบเราอัตโนมัติตอน login ครั้งแรก) เป็นจุดที่ map ตัวตนจาก provider เข้า user ของแอป:

📝 บทนี้ใช้ User entity ของบทนี้เอง (ไม่ใช่ตัวเดียวกับบท 3) — เพื่อรองรับ field ที่ OAuth/OIDC ใช้ (email, fullName) แยกออกจาก username/password ของ JWT แบบเขียนเองในบท 3:

Java ไม่มี property (get/set ในตัวภาษาแบบบางภาษา) — ต้องมี method getX()/setX() จริง ๆ เสมอ @Getter @Setter ของ Lombok แค่ generate method พวกนี้ให้อัตโนมัติตอน compile เท่านั้น ไม่ใช่ feature พิเศษของภาษา

java
// User entity เฉพาะของบทนี้ — self-contained
@Entity
@Table(name = "oauth_users")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class User {
    @Id @GeneratedValue
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    private String fullName;

    private String role;      // "USER", "ADMIN", ...
}

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}
java
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {

    private final UserRepository userRepo;

    // OAuth2AuthenticationException เป็น "checked exception" — Java บังคับให้ประกาศ `throws` ไว้ที่ signature
    // ถ้าไม่ประกาศจะ compile ไม่ผ่าน ต่างจาก DataIntegrityViolationException ด้านล่างซึ่งเป็น "unchecked"
    // (RuntimeException) — โยนได้โดยไม่ต้องประกาศ `throws` เลย
    @Override
    @Transactional  // ต้องมี transaction เพราะ userRepo.save() ถูกเรียกใน Spring Security filter chain
    public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(request);

        String email = oidcUser.getEmail();
        // กรณี 2 request เข้าพร้อมกัน (double-click / retry) อาจเกิด duplicate insert ได้
        // → ใช้ optimistic upsert: ลอง save ก่อน ถ้า unique constraint ชนกัน ค่อย findByEmail อีกครั้ง
        userRepo.findByEmail(email).orElseGet(() -> {
            try {
                // @AllArgsConstructor (Lombok) บน User entity สร้าง constructor นี้ให้อัตโนมัติ
                // (ดู User entity ด้านบน — ต้องมี @AllArgsConstructor ไม่งั้น compile ไม่ผ่าน)
                return userRepo.save(new User(null, email, oidcUser.getFullName(), "USER"));
            } catch (org.springframework.dao.DataIntegrityViolationException e) {
                // race condition: request อื่น insert ก่อนแล้ว → ดึงค่าที่มีอยู่แล้วกลับมา
                return userRepo.findByEmail(email).orElseThrow();
            }
        });

        return oidcUser;
    }
}

Wire ใน SecurityConfig — ต้อง inject CustomOidcUserService เข้า SecurityConfig ก่อน:

java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOidcUserService customOidcUserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/error", "/webjars/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth -> oauth
                .defaultSuccessUrl("/dashboard", true)
                .userInfoEndpoint(u -> u.oidcUserService(customOidcUserService))
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
            );
        return http.build();
    }
}

Part 3: Resource Server — verify JWT จาก provider

11. ใช้เมื่อไหร่?

มี Auth Provider ภายนอก (Keycloak, Auth0, AWS Cognito, Okta) ออก JWT
Spring Boot ของเรา = Resource Server ที่ verify token แล้วให้ access

text
Frontend → POST /login → Keycloak → JWT
Frontend → GET /api/users (Bearer JWT) → Spring Boot

                                     verify JWT (public key)

                                     allow / deny

12. Setup

ถ้าระบบเราเป็น Resource Server (verify JWT ที่ provider อื่นออก) ใช้ starter oauth2-resource-server — แค่ตั้ง issuer-uri Spring จะ fetch public key มา verify ลายเซ็น JWT ให้อัตโนมัติ ไม่ต้องเขียน filter เอง (ต่างจากบท 03 ที่เราออก JWT เอง):

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-keycloak.com/realms/myrealm
          # หรือ
          jwk-set-uri: https://your-keycloak.com/realms/myrealm/protocol/openid-connect/certs

Spring จะ fetch public key อัตโนมัติจาก issuer-uri → cache → ใช้ verify JWT signature


13. SecurityConfig

config ของ Resource Server — ตั้ง stateless (ไม่มี session, ทุก request พึ่ง JWT) + oauth2ResourceServer().jwt() จุดที่มักต้องเขียนเองคือ converter ที่แปลง claim (เคลม — ข้อมูลแต่ละชิ้นที่ token พกพา เช่น sub, email, roles) ของ provider (เช่น realm_access.roles ของ Keycloak) เป็น Spring authorities (สิทธิ์ในรูปแบบที่ Spring เข้าใจ) เพื่อใช้กับ @PreAuthorize:

java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth -> oauth
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            );
        return http.build();
    }

    // แปลง JWT claim "realm_access.roles" → Spring authorities
    //
    // คำอธิบาย: Keycloak ใส่ role ใน JWT ใต้ claim "realm_access.roles"
    //   - claim (เคลม) = ข้อมูลแต่ละชิ้นที่ token พกพา (เช่น sub, email, roles)
    //   - realm (เรล์ม) = "ขอบเขต" ของ Keycloak instance — 1 realm = 1 กลุ่ม user/role
    // แต่ Spring ต้องการ "authorities" (รายการสิทธิ์) ที่ทุกตัวขึ้นต้นด้วย "ROLE_"
    // → ต้องเขียน converter (ตัวแปลงรูปแบบ) เอง
    @Bean
    @SuppressWarnings("unchecked")
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter c = new JwtAuthenticationConverter();
        c.setJwtGrantedAuthoritiesConverter(jwt -> {
            Map<String, Object> realmAccess = jwt.getClaim("realm_access");
            if (realmAccess == null) return List.of();
            // null-guard: ถ้า key "roles" มีแต่ค่าเป็น null ก็คืน empty list (ป้องกัน NullPointerException)
            Object rolesObj = realmAccess.get("roles");
            List<String> roles = rolesObj instanceof List<?> list
                ? (List<String>) list
                : List.of();
            return roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                .collect(Collectors.toList());
        });
        return c;
    }
}

14. ใช้ใน Controller

ใน controller ดึงข้อมูลจาก JWT ที่ verify แล้วผ่าน @AuthenticationPrincipal Jwt (อ่าน claim ได้ตรง ๆ เช่น subject, email) และคุมสิทธิ์ด้วย @PreAuthorize เหมือนเดิม — เพราะ converter ในข้อก่อนหน้าแปลง role ให้แล้ว:

java
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
        // ⚠️ หมายเหตุ: Keycloak เก็บ roles ใต้ realm_access.roles ไม่ใช่ top-level claim "roles"
        // converter ใน §13 แปลงเป็น Spring authorities แล้ว ถ้าต้องการ roles ให้ดูผ่าน SecurityContext
        // ตัวอย่างนี้แสดงการอ่าน claim ตรง ๆ — ใช้ได้กับ provider ที่ใส่ "roles" ระดับบนสุด
        return Map.of(
            "sub", jwt.getSubject(),
            "email", jwt.getClaimAsString("email")
        );
    }

    @GetMapping
    @PreAuthorize("hasRole('ADMIN')")
    public List<User> getAll() {
        return userService.findAll();
    }

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_write')")  // ต้องการ scope "write" ถึงจะสร้าง user ได้
    public User create(@RequestBody User user) {
        return userService.save(user);
    }
}

15. ส่ง Request

ฝั่ง client แค่แนบ JWT ที่ได้จาก provider ใน Authorization: Bearer header — Spring จะ verify ลายเซ็น, เช็ค expiry, แปลง claim เป็น authentication ให้ทั้งหมดอัตโนมัติก่อนถึง controller:

bash
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
     http://localhost:8080/api/users/me

Spring จะ:

  1. อ่าน Authorization header
  2. parse JWT
  3. ดึง public key จาก issuer (cache)
  4. verify signature + expiry
  5. set Authentication ใน SecurityContext
  6. controller ดึงด้วย @AuthenticationPrincipal Jwt

16. Opaque Token (alternative)

Opaque Token (โอ-เพค โทเค่น — "token ทึบ" คือ token ที่ อ่านข้างในไม่ได้ตรง ๆ ต่างจาก JWT ที่ decode อ่าน claim ได้)

บาง provider ออก token แบบนี้ — เป็น random string (สตริงสุ่มที่ไม่มีโครงสร้าง) ที่ตัวมันเองไม่บอกอะไร Resource Server ต้องเรียก provider ผ่าน introspection endpoint (endpoint ที่ provider เปิดให้สอบถามว่า "token นี้ยังใช้ได้ไหม + เป็นของใคร") ทุกครั้งที่ verify

yaml
spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://provider.com/oauth2/introspect
          client-id: ${CLIENT_ID}
          client-secret: ${CLIENT_SECRET}
JWTOpaque
Verifylocal (signature)call provider
Speedเร็วช้า + network
Revokeไม่ได้ตรง ๆ — รอ expirerevoke ทันทีได้
Sizeใหญ่ (~1 KB)เล็ก (~40 chars)

ส่วนใหญ่ใช้ JWT + อายุสั้น (15 นาที) + refresh token


Part 4: Real-world scenarios

17. Scenario A: SPA + Spring Boot API (PKCE)

text
React SPA → Keycloak (PKCE flow) → Access Token (JWT)
React → GET /api/users (Bearer JWT) → Spring Boot Resource Server

Spring Boot: Resource Server config ที่กล่าวไว้ในส่วน Resource Server ข้างบน React: ใช้ react-oidc-context (wrap ของ oidc-client-ts) — รองรับ PKCE auto

tsx
// main.tsx
import { AuthProvider } from "react-oidc-context";

const oidcConfig = {
  authority: "https://keycloak.example.com/realms/myapp",
  client_id: "spa-client",
  redirect_uri: window.location.origin + "/callback",
  response_type: "code",          // ⭐ Authorization Code
  scope: "openid profile email",
  // PKCE เปิดอัตโนมัติโดย library
  automaticSilentRenew: true,     // ต่ออายุ token เงียบ ๆ (silent refresh)
  loadUserInfo: true,
};

createRoot(document.getElementById("root")!).render(
  <AuthProvider {...oidcConfig}>
    <App />
  </AuthProvider>
);
tsx
// App.tsx
import { useAuth } from "react-oidc-context";

function App() {
  const auth = useAuth();

  if (auth.isLoading) return <div>Loading...</div>;          {/* ข้อความที่แสดงบนหน้าเว็บ — เปลี่ยนเป็นภาษาไทยได้ */}
  if (auth.error) return <div>Error: {auth.error.message}</div>; {/* ข้อความ error บนหน้าเว็บ ไม่ใช่ error ของ Spring */}
  if (!auth.isAuthenticated) {
    return <button onClick={() => auth.signinRedirect()}>Login</button>;
  }

  return (
    <div>
      <p>Hi {auth.user?.profile.name}</p>
      <button onClick={() => auth.signoutRedirect()}>Logout</button>
      <UserList token={auth.user!.access_token} />
    </div>
  );
}

// ⚠️ ทางเลือกที่ดีกว่า: ใช้ useAuth() ใน UserList โดยตรงแทนการรับ token เป็น prop
// เพื่อลด surface area (พื้นที่เสี่ยง — จำนวนจุดที่ข้อมูลอ่อนไหวอาจรั่วออกไปได้) ที่ token จะถูก expose ใน component tree (XSS risk)
// (ใน App.tsx จริง ให้เพิ่ม import ที่ต้นไฟล์: import { useState, useEffect } from "react";)
type User = { id: number; name: string };
function UserList({ token }: { token: string }) {
  const [users, setUsers] = useState<User[]>([]);
  const [error, setError] = useState<Error | null>(null);
  useEffect(() => {
    fetch("/api/users", { headers: { Authorization: `Bearer ${token}` } })
      .then((r) => { if (!r.ok) throw new Error(r.statusText); return r.json(); })
      .then(setUsers)
      .catch(setError);
  }, [token]);
  if (error) return <div>โหลดข้อมูลไม่สำเร็จ: {error.message}</div>;
  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

🔗 ผูกกับ บท 3 §13.7 PKCE — เนื้อหา flow + code_verifier/code_challenge

17.1 Testing OAuth2 Resource Server

ทดสอบ endpoint ที่ require JWT โดยไม่ต้องมี real authority ก่อนอื่นต้องเพิ่ม dependency สำหรับ test:

xml
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
java
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
// static import นี้ทำให้เรียก jwt() สั้น ๆ ได้เลย

@WebMvcTest(UserController.class)
@Import(ResourceServerConfig.class)  // ใช้ ResourceServerConfig ไม่ใช่ SecurityConfig จาก §8
class UserControllerTest {

    @Autowired MockMvc mvc;

    @Test
    void requiresAuth() throws Exception {
        mvc.perform(get("/api/users")).andExpect(status().isUnauthorized());
    }

    @Test
    void allowsValidJwtForAdmin() throws Exception {
        mvc.perform(get("/api/users")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isOk());
    }

    @Test
    void rejectsMissingWriteScope() throws Exception {
        // POST /api/users ต้องการ SCOPE_write (ดู controller §14)
        // ถ้าส่งแค่ ROLE_ADMIN (ไม่มี SCOPE_write) จะได้ 403
        mvc.perform(post("/api/users").contentType("application/json").content("{}")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isForbidden());
    }
}

.with(jwt()) — เป็น static import จาก SecurityMockMvcRequestPostProcessors — ฉีด JwtAuthenticationToken เข้า SecurityContext โดยไม่ต้องมี JWT จริง / authority server จริง

สำหรับ OAuth2 client (login flow): ใช้ oauth2Login() post-processor + MockOAuth2Server (artifact: no.nav.security:mock-oauth2-server — library สำหรับจำลอง OAuth2/OIDC server ในการทดสอบ)


18. Scenario B: Login with Google + custom JWT

รูปแบบที่พบบ่อยในงานจริง — ใช้ Google ยืนยันตัวตนตอน login แต่หลังจากนั้นออก JWT ของระบบเราเองให้ client ใช้ (ไม่ใช้ token ของ Google ตรง ๆ) ได้ทั้งความสะดวกของ social login + ความยืดหยุ่นของ token ที่เราคุมเอง (ผสม OAuth2 client กับ JWT จากบท 3):

text
React → "Login with Google" → Google OAuth → callback ที่ Spring Boot
Spring Boot → verify Google token → ดึง email → สร้าง user → ออก JWT ของเราเอง
React → ใช้ JWT ของเราเอง เรียก API

= ผสม OAuth2 client + JWT custom (จากบทที่ 3)


19. Scenario C: Service-to-Service (Client Credentials)

OAuth ไม่ได้มีแค่สำหรับ user — เมื่อ service คุยกับ service (ไม่มี user เกี่ยวข้อง) ใช้ "client credentials" grant: service ใช้ client-id/secret แลก token เองเพื่อเรียก API อีกตัว ต่างจาก flow ที่ผ่านมาตรงที่ไม่มีหน้า login ของ user:

yaml
spring:
  security:
    oauth2:
      client:
        registration:
          payment-service:
            client-id: ${PAYMENT_CLIENT_ID}
            client-secret: ${PAYMENT_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: payment.write
        provider:
          payment-service:
            token-uri: https://auth.company.com/oauth2/token
java
// RestClient = HTTP client ตัวใหม่ของ Spring 6.1 สำหรับเรียก API ภายนอก (จะลงลึกในบทที่ 8)
// OAuth2AuthorizedClientManager = ตัวจัดการขอ/cache token ให้อัตโนมัติ
//   Spring Boot 3.2+ auto-configure ให้แล้ว ถ้าใช้เวอร์ชันเก่ากว่าต้องประกาศ @Bean เอง
@Service
@RequiredArgsConstructor
public class PaymentClient {
    // หมายเหตุ: restClient สร้างแบบ inline (ไม่ผ่าน Spring context)
    // ใน production ควรประกาศเป็น @Bean แยก เพื่อตั้งค่า SSL / interceptor ได้
    private final RestClient restClient = RestClient.builder()
        .baseUrl("https://payment.company.com")
        .build();

    private final OAuth2AuthorizedClientManager clientManager;

    public Payment createPayment(BigDecimal amount) {
        OAuth2AuthorizeRequest req = OAuth2AuthorizeRequest
            .withClientRegistrationId("payment-service")
            .principal("system")
            .build();

        String token = clientManager.authorize(req).getAccessToken().getTokenValue();

        return restClient.post()
            .uri("/payments")
            .header("Authorization", "Bearer " + token)
            .body(new PaymentRequest(amount))
            .retrieve()
            .body(Payment.class);
    }
}

20. ⚠️ OAuth2 Pitfalls

Pitfallแก้
เก็บ access token ใน localStorageXSS risk (Cross-Site Scripting — โจรฝัง JS ในเว็บเรา แล้วอ่าน token ออกไป) — ใช้ HttpOnly cookie (cookie ที่ JS อ่านไม่ได้) หรือเก็บใน memory (JavaScript variable ที่อยู่ใน browser — หายตอน refresh หน้า)
ไม่ใช้ PKCE ใน SPA✅ ใช้ PKCE — มาตรฐานแล้ว
Access token อายุยาวสั้น (15 นาที) + refresh token
ไม่ verify aud claimverify ว่า token มี audience (ผู้รับที่ตั้งใจ) = service ของเรา — ถ้าไม่ verify โจรเอา token ของ service อื่นมาใช้ได้ — ตั้งค่าใน Spring: spring.security.oauth2.resourceserver.jwt.audiences: my-service
HTTPS ไม่บังคับบังคับใน production — token หลุดได้
ผูก scope กับ role 1-1scope = สิ่งที่ client ขอ (เช่น read:photos), role = สิ่งที่ user มี (เช่น ADMIN) — แยกกัน

21. Checkpoint

🛠️ Checkpoint 7.1 — Login with Google
ทำ Spring Boot ที่:

  1. หน้า / แสดง "Login with Google"
  2. หลัง login → redirect ไป /dashboard
  3. /me แสดง email + name + picture
  4. ถ้า user ใหม่ → save ลง DB

🛠️ Checkpoint 7.2 — Resource Server

  1. รัน Keycloak ใน Docker:
    bash
    docker run -p 8180:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
      quay.io/keycloak/keycloak:latest start-dev
  2. เข้า http://localhost:8180 → สร้าง realm → สร้าง client (grant type: client_credentials) → สร้าง user
  3. ทำ Spring Boot Resource Server ที่ verify JWT จาก Keycloak
  4. ทดสอบด้วย (path เต็มของ token endpoint):
    bash
    TOKEN=$(curl -s -X POST "http://localhost:8180/realms/myrealm/protocol/openid-connect/token" \
      -d "grant_type=client_credentials&client_id=my-client&client_secret=my-secret" \
      | jq -r .access_token)
    # ⚠️ grant_type=client_credentials เหมาะกับ local testing — ไม่ใช้ password grant ที่ deprecated แล้ว
    curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/users/me

🛠️ Checkpoint 7.3 — Service to Service
ทำ 2 service ที่คุยกันด้วย client_credentials


22. สรุปบท

✅ OAuth2 = "อนุญาตให้ app เข้าถึง resource โดยไม่ต้องให้ password"
✅ 3 flow ที่ใช้จริง: Authorization Code (web), PKCE (SPA/mobile), Client Credentials (server) + 2 flow deprecated: Implicit, Password Grant
✅ OIDC = OAuth2 + Authentication — มี ID Token (JWT มี user info)
✅ "Login with X" → spring-boot-starter-oauth2-client + config provider
✅ Resource Server → spring-boot-starter-oauth2-resource-server + JWT issuer-uri
✅ Mapping JWT claims → Spring authorities ด้วย JwtAuthenticationConverter
✅ Hook OidcUserService เพื่อ save user ลง DB ครั้งแรก
✅ Production: HTTPS + short-lived access token + refresh token + HttpOnly cookie


← บทที่ 6 | บทที่ 8 → Microservices + Spring Cloud