---
doc_id: authserver-resource-server-integration-guide
title: AuthServer 資源服務與 BFF 整合手冊
description: 說明後端資源服務與 BFF 如何透過 JWT、JWKS、Introspect 與 Webhook 整合 AuthServer。
slug: /platform/authserver-resource-server-integration-guide
product: AuthServer
category: integration-guide
audience:
  - developer
  - architect
visibility: public
status: published
version: 1.0.0
owner: aile-platform
updated_at: 2026-07-14
tags:
  - AuthServer
  - JWT
  - JWKS
  - BFF
  - OAuth
  - 整合手冊
rendered_html: /rendered/platform/authserver-resource-server-integration-guide/
download: true
---

# AuthServer 資源服務與 BFF 整合手冊

> **公開發布說明**：本文中的網域、手機號碼、OTP、Token 與密鑰值均為占位示例，不可直接用於正式環境。

> **目標受眾**：需要與 AuthServer（統一登入認證中心）對接的後端資源服務、BFF 層開發者。
> **AuthServer 版本**：基於 `springcloud-aile/authserver` 原始碼分析（`release` 分支）。

---

## 一、AuthServer 架構概覽

```
┌─────────────────────────────────────────────────────┐
│                   AuthServer (:8080)                  │
│                                                       │
│  ┌─────────────┐  ┌──────────────┐  ┌─────────────┐ │
│  │  JWT 簽發    │  │  Token 驗證   │  │  信任事件    │ │
│  │  (RS256)    │  │  (introspect) │  │  (webhook)  │ │
│  └──────┬──────┘  └──────┬───────┘  └──────┬──────┘ │
│         │                │                  │         │
│  ┌──────┴────────────────┴──────────────────┴──────┐ │
│  │               JWKS 公鑰分發                       │ │
│  │            GET /oauth2/jwks                       │ │
│  └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
         │                    ▲                ▲
         │ 登入 → JWT          │ Token 驗證      │ 信任事件推送
         ▼                    │                │
   ┌──────────┐    ┌──────────┴──┐    ┌───────┴──────────┐
   │  客戶端    │    │  資源服務     │    │  資源服務 webhook  │
   │ (BFF/App) │    │ (SpringCloud)│    │  (接收信任事件)    │
   └──────────┘    └─────────────┘    └──────────────────┘
```

### 核心特徵

| 特徵 | 說明 |
|------|------|
| **簽名演算法** | RS256（RSA 2048-bit 金鑰對） |
| **Token 格式** | JWT（Header: `{"alg":"RS256","type":"oauth2","kid":"<SHA-256>"}` |
| **Token 傳輸** | HTTP Header `satoken: <jwt_string>` |
| **公鑰分發** | `GET /oauth2/jwks` 回傳 JWKS 格式公鑰 |
| **Token 有效期** | 預設 30 天（`jwt.expiration: 2592000` 秒） |
| **Token 撤銷** | 支援 jti 級別和 accountId 級別黑名單 |
| **客戶端認證** | OAuth Client（clientId + 可選 clientSecret） |

---

## 二、JWT Token 結構

### 2.1 JWT Header

```json
{
  "alg": "RS256",
  "type": "oauth2",
  "kid": "<SHA-256 of public key modulus, base64url>"
}
```

### 2.2 JWT Payload（Claims）

| Claim | 型別 | 必填 | 說明 |
|-------|------|------|------|
| `iss` | string | ✓ | 簽發者，預設 `http://localhost:8080`，正式環境應設定為 AuthServer 域名 |
| `sub` | string | ✓ | 帳號唯一標識（accountId） |
| `mobile` | string | ✓ | 手機號碼（不含國家區號） |
| `countryCode` | string | | 國家區號，如 `+886` |
| `iat` | number | ✓ | 簽發時間（Unix epoch 秒） |
| `exp` | number | ✓ | 過期時間（Unix epoch 秒） |
| `jti` | string | ✓ | JWT 唯一 ID（UUID 格式），用於精確撤銷 |
| `login_method` | string | | 登入方式：`3`=OTP, `4`=PASSWORD, `2`=CHANNEL, `7`=QRCODE |
| `channel` | string | | 應用渠道（來自 deviceData.channel 列舉值） |
| `thirdChannel` | string | | 第三方登入渠道（僅第三方登入 token 包含） |
| `scopeId` | string | | 第三方渠道 scopeId（僅第三方登入 token 包含） |
| `rememberMe` | boolean | ✓ | 是否記住此裝置登入 |

### 2.3 Token 有效期

預設 30 天，可透過設定調整：

```yaml
# AuthServer 設定
jwt:
  expiration: 2592000  # 秒，預設 30 天
```

> **建議**：資源服務的本地快取/驗證邏輯應容忍 Token 過期時間的合理時鐘偏差（建議 ±5 分鐘）。

---

## 三、資源服務整合

> **資源服務**：指需要驗證請求方 JWT Token 的後端微服務（如 `aile-service-account`、`aile-service-room` 等）。

### 3.1 整合模式選擇

| 模式 | 適用場景 | 複雜度 |
|------|---------|--------|
| **A. 本地驗證** | 高吞吐服務，避免每次請求都調 AuthServer | 中等 |
| **B. 遠端 Introspect** | 簡單整合，可容忍一次額外 HTTP 呼叫 | 低 |
| **C. 混合模式** | 本地驗證 + 信任事件訂閱，兼顧效能與安全性 | 較高 |

### 3.2 模式 A：本地 JWT 驗證（推薦）

**原理**：資源服務從 JWKS 端點取得 AuthServer 公鑰，在本地驗證 JWT 簽名和有效期。

#### Step 1：取得 AuthServer 公鑰

```bash
curl -s https://auth.example.com/oauth2/jwks | jq '.'
```

響應示例：

```json
{
  "success": true,
  "data": {
    "keys": [
      {
        "kty": "RSA",
        "kid": "abc123...base64url",
        "use": "sig",
        "alg": "RS256",
        "n": "<modulus base64url>",
        "e": "<exponent base64url>"
      }
    ]
  },
  "requestId": "..."
}
```

#### Step 2：實現本地 JWT 驗證

**Spring Boot 實現（推薦 Filter/Interceptor 模式）**：

```java
// 1. 定義 JWKS 快取設定
@Configuration
public class AuthServerJwksConfig {

    @Value("${auth.server.jwks-uri:http://authserver/oauth2/jwks}")
    private String jwksUri;

    @Bean
    public RSAPublicKey authServerPublicKey() {
        // 從 JWKS 端點取得並快取公鑰
        // 建議快取 TTL = 1 小時，定期重新整理
        JWKSet jwkSet = fetchJwks(jwksUri);
        return extractRsaPublicKey(jwkSet);
    }
}

// 2. 實現認證攔截器
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) {
        String token = request.getHeader("satoken");  // 與 AuthServer 一致的 header 名

        if (token == null) {
            response.setStatus(401);
            response.getWriter().write("{\"error\":\"MISSING_TOKEN\"}");
            return;
        }

        try {
            // 解析並驗證 JWT
            JWT jwt = JWTUtil.parseToken(token);
            // 使用公鑰驗證簽名
            boolean valid = jwt.setSigner(JWTSignerUtil.createSigner("RS256", publicKey)).verify();
            if (!valid) {
                throw new JWTException("簽名無效");
            }

            // 檢查過期
            JSONObject payload = jwt.getPayloads();
            long exp = payload.getLong("exp", 0);
            if (exp < System.currentTimeMillis() / 1000) {
                throw new JWTException("令牌已過期");
            }

            // 提取使用者資訊
            String accountId = payload.getStr("sub");
            request.setAttribute("accountId", accountId);
            request.setAttribute("tokenClaims", payload);

            chain.doFilter(request, response);
        } catch (Exception e) {
            response.setStatus(401);
            response.getWriter().write("{\"error\":\"INVALID_TOKEN\"}");
        }
    }
}
```

#### Step 3：使用統一的 Token Header 名稱

```properties
# 與 AuthServer 設定保持一致，預設為 satoken
sa-token.token-name=satoken
```

---

**非 Java 實現參考**：

**Python（PyJWT + cryptography）**：

```python
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
import json

class AuthServerValidator:
    def __init__(self, jwks_url, issuer):
        self.jwks_url = jwks_url
        self.issuer = issuer
        self._public_keys = {}  # kid -> public_key

    def _fetch_jwks(self):
        resp = requests.get(self.jwks_url)
        jwks = resp.json()["data"]
        for key in jwks["keys"]:
            kid = key["kid"]
            # 將 JWK 轉為 PEM
            from cryptography.hazmat.primitives.asymmetric import rsa
            n = int.from_bytes(base64.urlsafe_b64decode(key["n"] + "=="), "big")
            e = int.from_bytes(base64.urlsafe_b64decode(key["e"] + "=="), "big")
            public_key = rsa.RSAPublicNumbers(e, n).public_key(default_backend())
            self._public_keys[kid] = public_key

    def validate(self, token: str) -> dict:
        # 先解析 header 取得 kid
        unverified = jwt.get_unverified_header(token)
        kid = unverified.get("kid")

        if kid not in self._public_keys:
            self._fetch_jwks()

        public_key = self._public_keys[kid]
        claims = jwt.decode(
            token,
            public_key,
            algorithms=["RS256"],
            issuer=self.issuer,
            options={"verify_exp": True}
        )
        return claims
```

**Go（golang-jwt）**：

```go
import (
    "crypto/rsa"
    "encoding/base64"
    "math/big"
    "github.com/golang-jwt/jwt/v5"
)

func validateToken(tokenString string, publicKey *rsa.PublicKey, issuer string) (*jwt.MapClaims, error) {
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return publicKey, nil
    },
        jwt.WithIssuer(issuer),
        jwt.WithLeeway(5*time.Minute),  // 5 分鐘時鐘偏差容忍
    )
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return &claims, nil
}
```

**Node.js（jsonwebtoken + jwks-rsa）**：

```typescript
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: 'https://auth.example.com/oauth2/jwks',
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 3600000, // 1 小時
});

function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

export async function validateToken(token: string): Promise<jwt.JwtPayload> {
  return new Promise((resolve, reject) => {
    jwt.verify(token, getKey, {
      algorithms: ['RS256'],
      issuer: 'https://auth.example.com',
    }, (err, decoded) => {
      if (err) return reject(err);
      resolve(decoded as jwt.JwtPayload);
    });
  });
}
```

### 3.3 模式 B：遠端 Introspect 驗證

**適用場景**：不想管理公鑰快取，每次驗證都調 AuthServer。

```
資源服務 ──GET /oauth2/introspect──▶ AuthServer
   │         Header: satoken: <jwt>
   │
   ◀── 200 { active: true/false, sub, exp, iat, jti }
```

```bash
# 驗證 Token
curl -s https://auth.example.com/oauth2/introspect \
  -H "satoken: <使用者的JWT>" | jq '.'
```

響應：

```json
{
  "success": true,
  "data": {
    "active": true,
    "sub": "acc_xxx",
    "iss": "http://localhost:8080",
    "exp": 1750000000,
    "iat": 1720000000,
    "jti": "uuid-xxx"
  },
  "requestId": "..."
}
```

> **注意**：Introspect 端點本身需要攜帶有效的 `satoken` 頭。對於服務間呼叫，建議使用模式 A（本地驗證）+ 模式 C（信任事件），而不是讓每個資源服務都持有一個"服務帳號"的 Token 去做 introspect。

### 3.4 模式 C：混合模式（本地驗證 + 信任事件訂閱）

**推薦用於正式環境**。在模式 A 的基礎上增加 Token 撤銷事件的實時感知。

#### Step 1：註冊資源服務 webhook

```bash
curl -s -X POST https://auth.example.com/internal/v1/resource-servers/register \
  -H "X-AS-Registration-Token: <registration-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "serviceCode": "aile-service-account",
    "serviceName": "Account Service",
    "webhookUrl": "https://account-service.example.com/auth/events",
    "webhookSecret": "<webhook-hmac-secret>",
    "eventTypes": ["TokenInvalidated", "AccountTokensInvalidated", "ResourcePublicKeyRemoved"],
    "enabled": true
  }'
```

#### Step 2：實現 webhook 接收端點

```java
@RestController
@RequestMapping("/auth/events")
public class AuthEventController {

    @Value("${auth.server.webhook.secret}")
    private String webhookSecret;

    @PostMapping
    public ResponseEntity<Void> handleEvent(
        @RequestHeader("X-AS-Event-Id") String eventId,
        @RequestHeader("X-AS-Event-Type") String eventType,
        @RequestHeader("X-AS-Timestamp") String timestamp,
        @RequestHeader("X-AS-Signature") String signature,
        @RequestBody String body
    ) {
        // 1. 驗證 HMAC 簽名
        String expectedSig = hmacSha256(webhookSecret, timestamp + "." + body);
        if (!MessageDigest.isEqual(expectedSig.getBytes(), signature.getBytes())) {
            return ResponseEntity.status(403).build();
        }

        // 2. 處理事件
        JsonNode event = objectMapper.readTree(body);
        switch (eventType) {
            case "TokenInvalidated":
                String jti = event.get("jti").asText();
                localBlacklist.add(jti);      // 加入本地黑名單
                break;
            case "AccountTokensInvalidated":
                String accountId = event.get("accountId").asText();
                accountBlacklist.add(accountId);
                break;
            case "ResourcePublicKeyRemoved":
                refreshPublicKeyCache();       // 重新整理公鑰快取
                break;
        }

        return ResponseEntity.ok().build();
    }

    private String hmacSha256(String secret, String data) {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
    }
}
```

#### Step 3：驗證時加入本地黑名單檢查

```java
// 在 JwtAuthenticationFilter 中增加黑名單檢查
if (localBlacklist.contains(jti) || accountBlacklist.contains(accountId)) {
    response.setStatus(401);
    response.getWriter().write("{\"error\":\"TOKEN_REVOKED\"}");
    return;
}
```

### 3.5 Token 撤銷

資源服務需要知道何時 Token 被撤銷。兩種方式：

| 方式 | 機制 |
|------|------|
| **主動查詢** | 呼叫 `GET /oauth2/introspect`，檢查 `active` 欄位 |
| **被動接收** | 註冊 webhook，接收 `TokenInvalidated` / `AccountTokensInvalidated` 事件 |

> AuthServer 的 Token 黑名單儲存在 Redis 中，資源服務無法直接存取。建議在資源服務側維護一個程序內（或本地快取）的黑名單，透過 webhook 事件來更新。

---

## 四、BFF 層整合

> **BFF（Backend For Frontend）** 層：面向客戶端的 API 閘道器或聚合層，負責接收客戶端請求、管理會話、轉發給下游資源服務。

### 4.1 BFF 的職責

```
客戶端 App / Web
       │
       ▼
   ┌──────────────────────┐
   │  BFF (aile-gateway    │
   │  或 自建 BFF)          │
   │                       │
   │ ① 管理使用者登入會話      │
   │ ② 透傳 satoken 到      │
   │   下游資源服務           │
   │ ③ 呼叫 AuthServer     │
   │   登入/驗證 API         │
   └──────┬───────────────┘
          │ Feign / HTTP + satoken header
          ▼
   ┌──────────────┐
   │  資源服務       │
   │  (本地驗證JWT)  │
   └──────────────┘
```

### 4.2 BFF 呼叫 AuthServer 登入 API

#### 場景一：OTP 簡訊驗證碼登入

```bash
# Step 1: 傳送 OTP（取得 onceToken）
curl -s -X POST https://auth.example.com/api/v1/otp/send \
  -H "Content-Type: application/json" \
  -d '{
    "countryCode": "<country-code>",
    "mobile": "<mobile-number>"
  }'

# 響應：{ "data": { "onceToken": "ot_xxx" } }

# Step 2: 使用 OTP + onceToken 完成登入
curl -s -X POST https://auth.example.com/api/v1/login/otp \
  -H "Content-Type: application/json" \
  -H "deviceData: {...}" \
  -d '{
    "onceToken": "ot_xxx",
    "otp": "<otp-code>",
    "clientId": "aile-web"
  }'

# 響應：
# 200: { "data": { "token": "<JWT>", "newAccount": false, "accountId": "acc_xxx" } }
# 201: 新註冊使用者（newAccount=true）
```

#### 場景二：帳號密碼登入

```bash
curl -s -X POST https://auth.example.com/api/v1/login/password \
  -H "Content-Type: application/json" \
  -d '{
    "countryCode": "<country-code>",
    "mobile": "<mobile-number>",
    "password": "<password>",
    "clientId": "aile-web"
  }'

# 響應：{ "data": { "token": "<JWT>", "tokenType": "Bearer", "expiresIn": 2592000, "accountId": "acc_xxx", "phone": "<mobile-number>" } }
```

#### 場景三：第三方渠道登入（微信/LINE/Apple 等）

```bash
curl -s -X POST https://auth.example.com/api/v1/login/channel \
  -H "Content-Type: application/json" \
  -d '{
    "thirdChannel": "WECHAT",
    "scopeId": "openid_xxx",
    "clientId": "aile-web"
  }'

# 已繫結手機號：{ "data": { "needBindPhone": false, "token": "<JWT>", ... } }
# 未繫結手機號：{ "data": { "needBindPhone": true, "bindToken": "bt_xxx", "bindTokenExpiresIn": 600 } }
```

### 4.3 BFF 透傳 Token 到下游資源服務

BFF 拿到 AuthServer 簽發的 JWT 後，應將其透傳給下游資源服務：

```java
// Feign 攔截器自動注入 satoken header
@Component
public class AuthTokenFeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        // 從當前請求上下文取得 token
        String token = AuthContext.getCurrentToken();
        if (token != null) {
            template.header("satoken", token);  // 與 AuthServer 設定一致
        }
    }
}
```

```java
// 請求上下文（ThreadLocal）
public class AuthContext {
    private static final ThreadLocal<String> TOKEN_HOLDER = new ThreadLocal<>();
    private static final ThreadLocal<String> ACCOUNT_ID_HOLDER = new ThreadLocal<>();

    public static void setToken(String token) { TOKEN_HOLDER.set(token); }
    public static String getCurrentToken() { return TOKEN_HOLDER.get(); }
    public static void setAccountId(String id) { ACCOUNT_ID_HOLDER.set(id); }
    public static String getCurrentAccountId() { return ACCOUNT_ID_HOLDER.get(); }
    public static void clear() { TOKEN_HOLDER.remove(); ACCOUNT_ID_HOLDER.remove(); }
}
```

```java
// BFF Filter
@WebFilter(urlPatterns = "/api/*")
public class BffAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) {
        String token = request.getHeader("satoken");

        if (token == null) {
            response.setStatus(401);
            return;
        }

        try {
            // 可選：先驗證一次 JWT
            TokenClaims claims = tokenService.parseAndValidate(token);
            AuthContext.setToken(token);
            AuthContext.setAccountId(claims.getSubject());
            chain.doFilter(request, response);
        } catch (Exception e) {
            response.setStatus(401);
        } finally {
            AuthContext.clear();
        }
    }
}
```

### 4.4 登出

```bash
curl -s -X POST https://auth.example.com/api/v1/login/logout \
  -H "satoken: <JWT>"

# 響應始終 200（冪等）
```

登出後，AuthServer 將 JWT 加入黑名單。如果資源服務訂閱了 `TokenInvalidated` webhook 事件，也會收到相應通知。

### 4.5 Account Scope 查詢

BFF 在新使用者註冊前可查詢使用者是否已存在於生態中：

```bash
curl -s -X POST https://auth.example.com/account/scope/v1/real/check \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "WECHAT",
    "scopeId": "openid_xxx"
  }'

# 響應：{ "data": true } // true=已存在且繫結手機號, false=不存在或未繫結
```

### 4.6 OAuth Client 註冊（BFF 作為客戶端）

如果你的 BFF 是新客戶端，需要在 AuthServer 註冊：

```bash
# 透過 Admin API 註冊（需要 admin 許可權）
curl -s -X POST https://auth.example.com/admin/v1/oauth-clients \
  -H "Content-Type: application/json" \
  -H "satoken: <admin-token>" \
  -d '{
    "clientId": "my-bff-app",
    "clientName": "My BFF Application",
    "requireSecret": true,
    "clientSecret": "<oauth-client-secret>",
    "allowOtpLogin": true,
    "allowPasswordLogin": true,
    "allowChannelLogin": true,
    "remark": "自建 BFF 客戶端"
  }'
```

客戶端設定項說明：

| 欄位 | 說明 |
|------|------|
| `clientId` | 客戶端唯一標識 |
| `clientName` | 顯示名稱 |
| `requireSecret` | 是否要求 clientSecret（true=機密客戶端, false=公開客戶端） |
| `allowOtpLogin` | 是否允許 OTP 登入 |
| `allowPasswordLogin` | 是否允許密碼登入 |
| `allowChannelLogin` | 是否允許渠道登入 |
| `enabled` | 是否啟用 |

---

## 五、安全最佳實踐

### 5.1 Token 傳輸安全

```
客戶端 ──HTTPS──▶ BFF ──內部網路 HTTP──▶ 資源服務
  satoken              satoken            本地驗證
```

- **客戶端 ↔ BFF**：必須使用 HTTPS，防止 Token 被中間人截獲。
- **BFF ↔ 資源服務**：內網環境可 HTTP，但應透過網路策略隔離。
- **不要在 URL 參數中傳遞 Token**。

### 5.2 公鑰輪轉

AuthServer 支援金鑰輪轉（透過 JWKS 回傳多把 key，用 `kid` 區分）：

1. AuthServer 更換金鑰對（更新 `jwt.private-key` / `jwt.public-key` 設定）。
2. JWKS 端點回傳新 `kid`。
3. 資源服務從 JWKS 重新整理公鑰快取。
4. 新簽發的 JWT 使用新 `kid`，舊 JWT 在有效期內仍可用舊公鑰驗證。

> **建議**：資源服務的公鑰快取 TTL 設定為 1 小時以內，確保能及時感知金鑰輪轉。

### 5.3 Token 黑名單管理

- **jti 黑名單**：精確撤銷單個 Token（使用者主動登出）。
- **accountId 黑名單**：撤銷某帳號所有 Token（密碼修改、帳號停用）。
- 黑名單儲存在 Redis 中。資源服務不應直接存取 AuthServer 的 Redis，而應透過 webhook 事件取得撤銷通知。
- 本地黑名單建議僅保留 token 剩餘有效期內（最長 30 天）的資料，定期清理過期條目。

### 5.4 時鐘同步

JWT 的 `iat` 和 `exp` 基於 Unix 時間戳。所有服務（AuthServer + 資源服務 + BFF）必須透過 NTP 保持時鐘同步。

---

## 六、設定參考

### 6.1 AuthServer 關鍵設定

```yaml
# bootstrap.yml / Nacos
server:
  port: 8080

jwt:
  issuer: https://auth.your-domain.com       # 正式環境改為實際域名
  expiration: 2592000                         # 30 天
  private-key-path: ${JWT_PRIVATE_KEY_PATH}   # 私鑰由安全儲存掛載
  public-key-path: ${JWT_PUBLIC_KEY_PATH}     # 公鑰由部署環境掛載

sa-token:
  token-name: satoken                         # HTTP Header 名稱

as:
  resource-server:
    registration-token: "<registration-token>"   # 資源服務註冊所需令牌
```

### 6.2 資源服務關鍵設定

```yaml
# 資源服務側
auth:
  server:
    jwks-uri: https://auth.example.com/oauth2/jwks
    issuer: https://auth.your-domain.com
    local-blacklist:
      ttl: 2592000                # 黑名單 TTL，建議設為 token 有效期
      max-size: 10000             # 最大快取條目
    webhook:
      secret: "${AUTH_WEBHOOK_SECRET}"  # HMAC 簽名金鑰
```

### 6.3 BFF 關鍵設定

```yaml
# BFF 側
auth:
  server:
    base-url: https://auth.example.com
  client:
    id: my-bff-app
    secret: "${AUTH_CLIENT_SECRET}"
  token:
    header-name: satoken          # 與 AuthServer 一致
```

---

## 七、驗證清單

整合完成後，逐項驗證：

- [ ] **公鑰取得**：`GET /oauth2/jwks` 回傳正確的 RSA 公鑰（含 `kid`, `n`, `e`）。
- [ ] **JWT 簽名驗證**：資源服務用公鑰驗證 AuthServer 簽發的 JWT 透過。
- [ ] **JWT 過期驗證**：過期 Token 被正確拒絕。
- [ ] **黑名單驗證**：撤銷的 Token（jti 或 accountId）被正確拒絕。
- [ ] **登入流程**：BFF → AuthServer 登入 API 正常回傳 JWT。
- [ ] **Token 透傳**：BFF 將 satoken 正確透傳給下游資源服務。
- [ ] **登出流程**：登出後舊 Token 被撤銷。
- [ ] **Webhook 事件**：資源服務能接收並正確驗證 AuthServer 推送的信任事件簽名。
- [ ] **公鑰輪轉**：AuthServer 更換金鑰後，資源服務能在快取 TTL 內切換到新公鑰。

---

## 八、常見問題

**Q1：資源服務能直接存取 AuthServer 的資料庫/Redis 嗎？**

不能，也不應該。資源服務應透過 JWKS 端點取得公鑰，透過 webhook 接收信任事件。AuthServer 的內部儲存是實現細節，不應暴露給其他服務。

**Q2：Introspect 端點需要認證嗎？**

`GET /oauth2/introspect` 端點本身被 `SaTokenJwtAuthInterceptor` 保護（見 `WebMvcAuthConfig.java`），需要一個有效的 `satoken` 頭。這適合使用者 Token 的自檢場景。對於服務間驗證，建議使用本地 JWT 驗證（模式 A）。

**Q3：如果 Token 被盜用怎麼辦？**

AuthServer 支援 Token 撤銷（revoke）：POST /oauth2/revoke 將 Token 加入黑名單。資源服務透過 webhook 事件感知撤銷，拒絕已撤銷的 Token。

**Q4：客戶端需要同時支援 Web 和 App，如何區分？**

透過 `deviceData` header 中的 `channel` 欄位區分。Token 的 `channel` claim 會記錄簽發時的渠道資訊。

**Q5：Token 過期了怎麼辦？**

客戶端需要重新登入取得新 Token。BFF 可以攔截 401 響應，引導客戶端重新整理登入態。

---

> **文件版本**: v1.0  
> **最後更新**: 2026-07-14  
> **基於原始碼**: `springcloud-aile/authserver` release 分支
