# VSRA OAuth 2.0 第三方接入指南

> 版本：v1.1
> 更新日期：2025-01-30

## 目录

1. [概述](#1-概述)
2. [快速开始](#2-快速开始)
3. [认证流程详解](#3-认证流程详解)
4. [API 参考](#4-api-参考)
5. [敏感数据加密接口](#5-敏感数据加密接口)
6. [错误处理](#6-错误处理)
7. [安全最佳实践](#7-安全最佳实践)
8. [代码示例](#8-代码示例)
9. [常见问题](#9-常见问题)
10. [附录](#10-附录)

---

## 1. 概述

### 1.1 简介

VSRA（Visa Select Rewards App）OAuth 2.0 开放平台允许第三方应用安全地访问 VSRA 用户的数据，包括用户资料、绑定的 Visa 卡片信息、会员权益等。

本文档面向第三方应用开发者，详细介绍如何接入 VSRA OAuth 2.0 认证体系。

### 1.2 适用场景

-   使用 VSRA 账号登录第三方应用
-   获取用户的 Visa 卡片信息
-   获取用户的会员信息
-   查询用户的会员权益资格

### 1.3 接入前准备

在开始接入前，您需要：

1. **申请应用凭证**：联系 Visa 获取 `client_id` 和 `client_secret`
2. **提供回调地址**：提供应用回调 URL 地址
3. **确认权限范围**：根据业务需求申请相应的 scope 权限

### 1.4 基础信息

| 环境     | API 基础地址                        |
| -------- | ----------------------------------- |
| 生产环境 | `https://vsra.offerpluscn.com`      |
| 测试环境 | `https://vsra-test.offerpluscn.com` |

---

## 2. 快速开始

### 2.1 接入流程总览

```
┌─────────────────────────────────────────────────────────────────┐
│                        OAuth 2.0 接入流程                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Step 1: 获取应用凭证                                           │
│   ↓                                                             │
│   Step 2: 引导用户授权（跳转到 VSRA 授权页）                       │
│   ↓                                                             │
│   Step 3: 用户登录并授权                                         │
│   ↓                                                             │
│   Step 4: 获取授权码（code）                                     │
│   ↓                                                             │
│   Step 5: 用 code 换取 access_token                             │
│   ↓                                                             │
│   Step 6: 使用 access_token 调用 API                            │
│   ↓                                                             │
│   Step 7: access_token 过期后用 refresh_token 刷新               │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
```

### 2.2 接入

#### Step 1: 构造授权 URL

```javascript
const authUrl =
    "https://{接口网址}/oauth/authorize?" +
    new URLSearchParams({
        client_id: "your_client_id",
        redirect_uri: "https://your-app.com/callback",
        scope: "card",
        state: "random_state_string", // 用于防止 CSRF 攻击
    }).toString();

// 引导用户跳转
window.location.href = authUrl;
```

#### Step 2: 处理回调，获取 code

用户授权后，VSRA 会重定向到您的回调地址：

```
https://your-app.com/callback?code=AUTH_CODE&state=random_state_string
```

#### Step 3: 在应用后台用 code 换取 token

```bash
示例：
curl -X POST https://{接口网址}/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "AUTH_CODE",
    "client_id": "your_client_id",
    "client_secret": "your_client_secret",
    "redirect_uri": "https://your-app.com/callback"
  }'
```

响应：

```json
{
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "token_type": "Bearer",
    "expires_in": 7200,
    "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
    "scope": "card",
    "openid": "o_abc123def456"
}
```

#### Step 4: 调用业务 API

```bash
示例：
curl -X GET https://{接口网址}/oauth/api/cards \
  -H "Authorization: Bearer {access_token}"
```

---

## 3. 认证流程详解

### 3.1 授权码模式（Authorization Code Grant）

VSRA 采用 OAuth 2.0 认证：

#### 3.1.1 时序图

```
     用户          第三方前端              VSRA           第三方后端
      │               │                   │                 │
      │ 1.点击登录     │                   │                 │
      │──────────────>│                   │                 │
      │               │                   │                 │
      │               │ 2.重定向到授权页    │                 │
      │               │──────────────────>│                 │
      │               │                   │                 │
      │ 3.登录并授权   │                   │                 │
      │──────────────────────────────────>│                 │
      │               │                   │                 │
      │               │ 4.重定向+code      │                 │
      │               │<──────────────────│                 │
      │               │                   │                 │
      │               │ 5.发送code到后端   │                 │
      │               │─────────────────────────────────────>│
      │               │                   │                 │
      │               │                   │ 6.code换token    │
      │               │                   │<─────────────────│
      │               │                   │                 │
      │               │                   │ 7.返回token      │
      │               │                   │─────────────────>│
      │               │                   │                 │
      │               │ 8.登录成功         │                 │
      │               │<─────────────────────────────────────│
      │               │                   │                 │
```

### 3.2 权限范围（Scope）

VSRA 采用递进式权限设计，高级别 scope 自动包含低级别的所有权限。

| Scope         | 级别 | 授权方式   | 可访问数据                        |
| ------------- | ---- | ---------- | --------------------------------- |
| `base`        | 1    | 静默授权   | 仅 openid                         |
| `profile`     | 2    | 需用户确认 | openid + 昵称、头像、联系方式     |
| `card`        | 3    | 需用户确认 | profile 数据 + 卡片列表、会员信息 |
| `eligibility` | 4    | 需用户确认 | card 数据 + 权益资格查询          |

**权限递进关系：**

```
base → profile → card → eligibility
```

**示例：** 申请 `scope=card` 将获得 `base`、`profile`、`card` 的所有权限。

### 3.3 静默授权 vs 用户授权

| 类型         | 适用 Scope                     | 用户体验                       | 适用场景             |
| ------------ | ------------------------------ | ------------------------------ | -------------------- |
| **静默授权** | `base`                         | 用户登录后直接跳转，无需确认   | 仅需识别用户身份     |
| **用户授权** | `profile`/`card`/`eligibility` | 显示授权页面，用户需点击"同意" | 需要访问用户敏感数据 |

### 3.4 OpenID 说明

-   `openid` 是用户在您应用中的唯一标识
-   同一用户在不同应用的 openid 不同，无法跨应用追踪
-   openid 格式：`o_` + 28 位字符串，如 `o_abc123def456789xyz000111`
-   openid 是稳定的，同一用户多次授权获得的 openid 相同

---

## 4. API 参考

### 4.1 OAuth 端点

#### 4.1.1 授权端点

**请求：**

```
GET /oauth/authorize
```

**参数：**

| 参数          | 必填 | 类型   | 说明                                            |
| ------------- | ---- | ------ | ----------------------------------------------- |
| client_id     | 是   | string | 应用 ID                                         |
| redirect_uri  | 是   | string | 回调地址，需与注册时一致                        |
| scope         | 是   | string | 权限范围：`base`/`profile`/`card`/`eligibility` |
| state         | 是   | string | 随机字符串，用于防止 CSRF 攻击，会原样返回      |
| response_type | 否   | string | 固定为 `code`，可省略                           |

**示例：**

```
https://{接口网址}/oauth/authorize?client_id=cli_xxx&redirect_uri=https://example.com/callback&scope=card&state=abc123
```

**成功响应：**

用户授权后，重定向到：

```
{redirect_uri}?code=AUTH_CODE&state=abc123
```

**失败响应：**

用户拒绝或出错时，重定向到：

```
{redirect_uri}?error=access_denied&error_description=用户拒绝授权&state=abc123
```

---

#### 4.1.2 Token 端点

**请求：**

```
POST /oauth/token
Content-Type: application/json
```

##### 授权码换 Token

**参数：**

| 参数          | 必填 | 类型   | 说明                         |
| ------------- | ---- | ------ | ---------------------------- |
| grant_type    | 是   | string | 固定为 `authorization_code`  |
| code          | 是   | string | 授权码                       |
| client_id     | 是   | string | 应用 ID                      |
| client_secret | 是   | string | 应用密钥                     |
| redirect_uri  | 是   | string | 回调地址，需与授权请求时一致 |

**请求示例：**

```json
{
    "grant_type": "authorization_code",
    "code": "AUTH_CODE_HERE",
    "client_id": "cli_xxx",
    "client_secret": "sec_xxx",
    "redirect_uri": "https://example.com/callback"
}
```

**成功响应：**

```json
{
    "access_token": "64位随机字符串",
    "token_type": "Bearer",
    "expires_in": 7200,
    "refresh_token": "64位随机字符串",
    "scope": "card",
    "openid": "o_abc123def456"
}
```

| 字段          | 类型   | 说明                                           |
| ------------- | ------ | ---------------------------------------------- |
| access_token  | string | 访问令牌，用于调用 API                         |
| token_type    | string | 固定为 `Bearer`                                |
| expires_in    | int    | access_token 有效期（秒），默认 7200（2 小时） |
| refresh_token | string | 刷新令牌，用于获取新的 access_token            |
| scope         | string | 实际授权的权限范围                             |
| openid        | string | 用户在本应用的唯一标识                         |

##### 刷新 Token

**参数：**

| 参数          | 必填 | 类型   | 说明                   |
| ------------- | ---- | ------ | ---------------------- |
| grant_type    | 是   | string | 固定为 `refresh_token` |
| refresh_token | 是   | string | 刷新令牌               |
| client_id     | 是   | string | 应用 ID                |
| client_secret | 是   | string | 应用密钥               |

**请求示例：**

```json
{
    "grant_type": "refresh_token",
    "refresh_token": "REFRESH_TOKEN_HERE",
    "client_id": "cli_xxx",
    "client_secret": "sec_xxx"
}
```

**响应格式同上。**

> **注意：** 刷新后，原 access_token 和 refresh_token 将失效，请使用新返回的 token。

---

#### 4.1.3 撤销 Token

**请求：**

```
POST /oauth/revoke
Content-Type: application/json
```

**参数：**

| 参数            | 必填 | 类型   | 说明                                          |
| --------------- | ---- | ------ | --------------------------------------------- |
| token           | 是   | string | 要撤销的 token                                |
| token_type_hint | 否   | string | token 类型：`access_token` 或 `refresh_token` |
| client_id       | 是   | string | 应用 ID                                       |
| client_secret   | 是   | string | 应用密钥                                      |

**成功响应：**

```json
{
    "success": true
}
```

---

### 4.2 资源 API

所有资源 API 都需要在请求头中携带 access_token：

```
Authorization: Bearer {access_token}
```

---

#### 4.2.1 获取用户标识

**请求：**

```
GET /oauth/api/userinfo
```

**所需 Scope：** `base`

**响应：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456"
    }
}
```

---

#### 4.2.2 获取用户资料

**请求：**

```
GET /oauth/api/profile
```

**所需 Scope：** `profile`

**响应：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "nickname": "张三",
        "avatar": "https://cdn.vsra.com/avatar/xxx.jpg",
        "mobile": "138****1234",
        "email": "z***n@example.com"
    }
}
```

| 字段     | 类型   | 说明                                             |
| -------- | ------ | ------------------------------------------------ |
| openid   | string | 用户唯一标识                                     |
| nickname | string | 用户昵称                                         |
| avatar   | string | 头像 URL                                         |
| mobile   | string | 脱敏手机号（如 138\*\*\*\*1234）                 |
| email    | string | 脱敏邮箱（如 z\*\*\*n@example.com），可能为 null |

---

#### 4.2.3 获取卡片列表

**请求：**

```
GET /oauth/api/cards
```

**所需 Scope：** `card`

**响应：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "cards": [
            {
                "card_id": "card_xxx",
                "last_4": "1234",
                "product_level": "Visa Infinite",
                "product_level_display": "无限卡",
                "product_name": "Visa Infinite Card",
                "issuer_name": "中国银行",
                "issuer_code": "BOC"
            },
            {
                "card_id": "card_yyy",
                "last_4": "5678",
                "product_level": "Visa Platinum",
                "product_level_display": "白金卡",
                "product_name": "Visa Platinum Card",
                "issuer_name": "招商银行",
                "issuer_code": "CMB"
            }
        ]
    }
}
```

**卡片等级说明：**

| product_level           | product_level_display | 优先级 |
| ----------------------- | --------------------- | ------ |
| Visa Classic            | 普卡                  | 1      |
| Visa Gold               | 金卡                  | 2      |
| Visa Platinum           | 白金卡                | 3      |
| Visa Signature          | 御玺卡                | 4      |
| Visa Signature Business | 御玺商务卡            | 4      |
| Visa Infinite           | 无限卡                | 5      |

---

#### 4.2.4 获取会员信息

**请求：**

```
GET /oauth/api/memberships
```

**所需 Scope：** `card`

**响应：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "memberships": [
            {
                "partner_code": "gha",
                "partner_name": "GHA DISCOVERY",
                "level_id": "titanium",
                "level_name": "钛金会员",
                "membership_number": "GHA123456789",
                "status": "active"
            }
        ]
    }
}
```

| 字段              | 类型   | 说明                      |
| ----------------- | ------ | ------------------------- |
| partner_code      | string | 合作伙伴代码              |
| partner_name      | string | 合作伙伴名称              |
| level_id          | string | 会员等级 ID               |
| level_name        | string | 会员等级名称              |
| membership_number | string | 会员号                    |
| status            | string | 状态：`active`/`inactive` |

---

#### 4.2.5 查询权益资格

**请求：**

```
GET /oauth/api/eligibility/{oid}
```

**所需 Scope：** `eligibility`

**路径参数：**

| 参数 | 类型   | 说明         |
| ---- | ------ | ------------ |
| oid  | string | 礼遇/权益 ID |

**响应示例（符合资格）：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "oid": "offer_gha_discovery",
        "eligibleBenefit": {
            "status": "eligible",
            "message": null,
            "has_membership": true,
            "eligibility": {
                "type": "card_level",
                "current_value": "Visa Infinite",
                "current_display": "无限卡",
                "qualifying_cards": [
                    {
                        "last_4": "1234",
                        "issuer_name": "中国银行",
                        "product_level": "Visa Infinite",
                        "product_level_display": "无限卡",
                        "is_highest": true
                    }
                ]
            },
            "matched_level": {
                "id": "titanium",
                "name": "钛金会员",
                "name_en": "Titanium Member",
                "icon": "https://cdn.vsra.com/gha/titanium.png",
                "benefits": [
                    "铂金会员权益",
                    "行政酒廊使用权",
                    "早餐礼遇",
                    "专属礼宾服务"
                ]
            },
            "action": {
                "type": "external_link",
                "url": "https://ghaloyalty.com/member/login?partnerHash=xxx",
                "text": "前往激活会员",
                "membership_number": "GHA123456789"
            },
            "upgrade": null,
            "all_levels": [
                {
                    "id": "gold",
                    "name": "金卡会员",
                    "priority": 1
                },
                {
                    "id": "platinum",
                    "name": "铂金会员",
                    "priority": 2
                },
                {
                    "id": "titanium",
                    "name": "钛金会员",
                    "priority": 3
                }
            ]
        }
    }
}
```

**响应示例（不符合资格）：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "oid": "offer_gha_discovery",
        "eligibleBenefit": {
            "status": "ineligible",
            "message": "您当前的卡片等级暂不支持此权益",
            "has_membership": false,
            "eligibility": {
                "type": "card_level",
                "current_value": "Visa Classic",
                "current_display": "普卡",
                "qualifying_cards": []
            },
            "matched_level": null,
            "action": null,
            "upgrade": {
                "available": true,
                "next_level": {
                    "id": "gold",
                    "name": "金卡会员",
                    "name_en": "Gold Member",
                    "required_card_levels": ["Visa Gold", "Visa Business"],
                    "required_card_levels_display": ["金卡", "商务卡"]
                },
                "hint": "绑定金卡可升级至金卡会员"
            },
            "all_levels": [...]
        }
    }
}
```

**eligibleBenefit.status 状态说明：**

| status          | 说明                             |
| --------------- | -------------------------------- |
| `eligible`      | 符合资格                         |
| `ineligible`    | 不符合资格（卡片等级不足）       |
| `need_bindcard` | 需要先绑定卡片                   |
| `upgraded`      | 已升级（用户绑定了更高级别卡片） |

---

## 5. 敏感数据加密接口

某些业务场景需要获取用户的完整敏感数据（如完整手机号、完整卡号）。为确保安全，VSRA 提供加密接口，使用 RSA-OAEP 算法加密敏感数据。

### 5.1 前置条件

使用加密接口前，您需要：

1. **生成 RSA 密钥对**（2048-bit 以上）
2. **提供公钥给 VSRA**：联系 VSRA 技术团队配置您的 RSA 公钥
3. **妥善保管私钥**：私钥用于解密敏感数据

#### 生成 RSA 密钥对

```bash
# 生成 2048-bit RSA 私钥
openssl genrsa -out private_key.pem 2048

# 从私钥导出公钥
openssl rsa -in private_key.pem -pubout -out public_key.pem
```

### 5.2 加密算法

| 项目     | 配置        |
| -------- | ----------- |
| 算法     | RSA-OAEP    |
| 哈希算法 | SHA-256     |
| MGF      | MGF1-SHA256 |
| 密钥长度 | 2048-bit+   |

### 5.3 加密接口

#### 5.3.1 获取用户资料（含完整手机号）

**请求：**

```
GET /oauth/api/profile/sensitive
```

**所需 Scope：** `profile`

**前置条件：** 已配置 RSA 公钥

**响应：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "nickname": "张三",
        "avatar": "https://cdn.vsra.com/avatar/xxx.jpg",
        "mobile_masked": "138****1234",
        "email_masked": "z***n@example.com",
        "sensitive": "Base64编码的RSA加密数据",
        "encryption": {
            "algorithm": "RSA-OAEP",
            "hash": "SHA256",
            "mgf": "MGF1-SHA256"
        }
    }
}
```

**sensitive 字段解密后的内容：**

```json
{
    "mobile": "13812345678",
    "email": "zhangsan@example.com"
}
```

---

#### 5.3.2 获取卡片列表（含完整卡号）

**请求：**

```
GET /oauth/api/cards/sensitive
```

**所需 Scope：** `card`

**前置条件：** 已配置 RSA 公钥

**响应：**

```json
{
    "retCode": 0,
    "data": {
        "openid": "o_abc123def456",
        "cards": [
            {
                "card_id": "card_xxx",
                "last_4": "1234",
                "product_level": "Visa Infinite",
                "product_level_display": "无限卡",
                "product_name": "Visa Infinite Card",
                "issuer_name": "中国银行",
                "issuer_code": "BOC"
            }
        ],
        "sensitive": "Base64编码的RSA加密数据",
        "encryption": {
            "algorithm": "RSA-OAEP",
            "hash": "SHA256",
            "mgf": "MGF1-SHA256"
        }
    }
}
```

**sensitive 字段解密后的内容：**

```json
{
    "cards": [
        {
            "card_id": "card_xxx",
            "card_number": "4000123456781234",
            "expiry_month": "12",
            "expiry_year": "2028"
        }
    ]
}
```

### 5.4 解密示例

#### Node.js

```javascript
const crypto = require("crypto");
const fs = require("fs");

function decryptSensitiveData(encryptedBase64, privateKeyPath) {
    const privateKey = fs.readFileSync(privateKeyPath, "utf8");
    const encryptedBuffer = Buffer.from(encryptedBase64, "base64");

    const decrypted = crypto.privateDecrypt(
        {
            key: privateKey,
            padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
            oaepHash: "sha256",
        },
        encryptedBuffer
    );

    return JSON.parse(decrypted.toString("utf8"));
}

// 使用示例
const response = await fetch("/oauth/api/profile/sensitive", {
    headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json();

const sensitiveData = decryptSensitiveData(
    data.data.sensitive,
    "./private_key.pem"
);
console.log("完整手机号:", sensitiveData.mobile);
```

#### Python

```python
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
import base64
import json

def decrypt_sensitive_data(encrypted_base64: str, private_key_path: str) -> dict:
    with open(private_key_path, 'rb') as f:
        private_key = serialization.load_pem_private_key(f.read(), password=None)

    encrypted_data = base64.b64decode(encrypted_base64)

    decrypted = private_key.decrypt(
        encrypted_data,
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None
        )
    )

    return json.loads(decrypted.decode('utf-8'))

# 使用示例
sensitive_data = decrypt_sensitive_data(
    response_data['data']['sensitive'],
    'private_key.pem'
)
print(f"完整手机号: {sensitive_data['mobile']}")
```

#### PHP

```php
<?php
function decryptSensitiveData(string $encryptedBase64, string $privateKeyPath): array
{
    $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyPath));
    $encryptedData = base64_decode($encryptedBase64);

    $decrypted = '';
    openssl_private_decrypt(
        $encryptedData,
        $decrypted,
        $privateKey,
        OPENSSL_PKCS1_OAEP_PADDING
    );

    return json_decode($decrypted, true);
}

// 使用示例
$sensitiveData = decryptSensitiveData(
    $responseData['data']['sensitive'],
    'private_key.pem'
);
echo "完整手机号: " . $sensitiveData['mobile'];
```

### 5.5 错误处理

| retCode | error                      | 说明                          |
| ------- | -------------------------- | ----------------------------- |
| 400     | encryption_not_configured  | 应用未配置 RSA 公钥           |
| 500     | encryption_failed          | 加密失败（服务端错误）        |

---

## 6. 错误处理

### 6.1 OAuth 错误

OAuth 端点返回的错误遵循 OAuth 2.0 规范：

```json
{
    "error": "error_code",
    "error_description": "错误描述"
}
```

| error                  | HTTP Status | 说明                            |
| ---------------------- | ----------- | ------------------------------- |
| invalid_request        | 400         | 请求参数缺失或格式错误          |
| invalid_client         | 401         | client_id 或 client_secret 无效 |
| invalid_grant          | 400         | 授权码无效、已过期或已使用      |
| invalid_scope          | 400         | 请求的 scope 无效或超出允许范围 |
| unauthorized_client    | 401         | redirect_uri 不在白名单中       |
| access_denied          | 403         | 用户拒绝授权                    |
| unsupported_grant_type | 400         | 不支持的 grant_type             |

### 6.2 API 错误

资源 API 返回的错误格式：

```json
{
    "retCode": 401,
    "error": "unauthorized",
    "message": "未授权或 token 已过期"
}
```

| retCode | error              | 说明               |
| ------- | ------------------ | ------------------ |
| 401     | unauthorized       | Token 无效或已过期 |
| 403     | insufficient_scope | Token 权限不足     |
| 404     | not_found          | 资源不存在         |

### 6.3 错误处理建议

```javascript
async function callApi(endpoint, accessToken) {
    const response = await fetch(`https://{接口网址}${endpoint}`, {
        headers: {
            Authorization: `Bearer ${accessToken}`,
        },
    });

    if (response.status === 401) {
        // Token 过期，尝试刷新
        const newToken = await refreshAccessToken();
        if (newToken) {
            return callApi(endpoint, newToken);
        } else {
            // 刷新失败，需要重新授权
            redirectToAuth();
        }
    }

    if (response.status === 403) {
        // 权限不足，可能需要申请更高级别的 scope
        throw new Error("权限不足，请联系管理员");
    }

    return response.json();
}
```

---

## 7. 安全最佳实践

### 7.1 保护 client_secret

-   **永远不要**在前端代码中暴露 client_secret
-   client_secret 只能在后端服务器使用
-   定期轮换 client_secret

```javascript
// ❌ 错误：前端直接使用 client_secret
fetch("/oauth/token", {
    body: JSON.stringify({
        client_secret: "sec_xxx", // 危险！
    }),
});

// ✅ 正确：通过后端代理
fetch("/api/auth/callback", {
    body: JSON.stringify({ code: "xxx" }),
});
// 后端处理 client_secret
```

### 7.2 使用 state 参数防止 CSRF

```javascript
// 1. 生成随机 state 并存储
const state = crypto.randomUUID();
sessionStorage.setItem("oauth_state", state);

// 2. 发起授权请求
const authUrl = `https://{接口网址}/oauth/authorize?...&state=${state}`;

// 3. 回调时验证 state
const urlParams = new URLSearchParams(window.location.search);
const returnedState = urlParams.get("state");
const savedState = sessionStorage.getItem("oauth_state");

if (returnedState !== savedState) {
    throw new Error("State mismatch - possible CSRF attack");
}
```

### 7.3 安全存储 Token

```javascript
// ✅ 推荐：存储在后端 session 中

// ⚠️ 可接受：存储在 httpOnly cookie 中

// ❌ 不推荐：存储在 localStorage 中（容易被 XSS 攻击）
```

### 7.4 最小权限原则

只申请业务所需的最小 scope：

```javascript
// ❌ 不推荐：申请最高权限
scope: "eligibility";

// ✅ 推荐：按需申请
scope: "profile"; // 如果只需要用户资料
```

### 7.5 Token 过期处理

```javascript
// 存储 token 时记录过期时间
const tokenData = {
    access_token: response.access_token,
    refresh_token: response.refresh_token,
    expires_at: Date.now() + response.expires_in * 1000,
};

// 调用 API 前检查是否需要刷新
function getValidToken() {
    if (Date.now() > tokenData.expires_at - 60000) {
        // 提前 1 分钟刷新
        return refreshToken();
    }
    return tokenData.access_token;
}
```

---

## 8. 代码示例

### 8.1 Node.js (Express)

```javascript
const express = require("express");
const axios = require("axios");
const crypto = require("crypto");

const app = express();
const VSRA_API = "https://{接口网址}";
const CLIENT_ID = process.env.VSRA_CLIENT_ID;
const CLIENT_SECRET = process.env.VSRA_CLIENT_SECRET;
const REDIRECT_URI = "https://your-app.com/callback";

// Step 1: 发起授权
app.get("/auth/vsra", (req, res) => {
    const state = crypto.randomUUID();
    req.session.oauthState = state;

    const authUrl = new URL(`${VSRA_API}/oauth/authorize`);
    authUrl.searchParams.set("client_id", CLIENT_ID);
    authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
    authUrl.searchParams.set("scope", "card");
    authUrl.searchParams.set("state", state);

    res.redirect(authUrl.toString());
});

// Step 2: 处理回调
app.get("/callback", async (req, res) => {
    const { code, state, error } = req.query;

    // 检查错误
    if (error) {
        return res.status(400).json({ error });
    }

    // 验证 state
    if (state !== req.session.oauthState) {
        return res.status(400).json({ error: "State mismatch" });
    }

    try {
        // 用 code 换取 token
        const tokenResponse = await axios.post(`${VSRA_API}/oauth/token`, {
            grant_type: "authorization_code",
            code,
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
            redirect_uri: REDIRECT_URI,
        });

        const { access_token, refresh_token, openid, expires_in } =
            tokenResponse.data;

        // 存储 token（示例：存到 session）
        req.session.vsraToken = {
            access_token,
            refresh_token,
            openid,
            expires_at: Date.now() + expires_in * 1000,
        };

        res.redirect("/dashboard");
    } catch (err) {
        console.error("Token exchange failed:", err.response?.data);
        res.status(500).json({ error: "Authentication failed" });
    }
});

// Step 3: 调用 API
app.get("/api/cards", async (req, res) => {
    const token = await getValidToken(req);

    try {
        const response = await axios.get(`${VSRA_API}/oauth/api/cards`, {
            headers: { Authorization: `Bearer ${token}` },
        });
        res.json(response.data);
    } catch (err) {
        if (err.response?.status === 401) {
            // Token 失效，需要重新授权
            return res.status(401).json({ error: "Please re-authenticate" });
        }
        res.status(500).json({ error: "API call failed" });
    }
});

// 辅助函数：获取有效 token
async function getValidToken(req) {
    const tokenData = req.session.vsraToken;

    if (!tokenData) {
        throw new Error("Not authenticated");
    }

    // 检查是否需要刷新
    if (Date.now() > tokenData.expires_at - 60000) {
        const refreshResponse = await axios.post(`${VSRA_API}/oauth/token`, {
            grant_type: "refresh_token",
            refresh_token: tokenData.refresh_token,
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
        });

        const newToken = refreshResponse.data;
        req.session.vsraToken = {
            ...newToken,
            expires_at: Date.now() + newToken.expires_in * 1000,
        };

        return newToken.access_token;
    }

    return tokenData.access_token;
}

app.listen(3000);
```

### 8.2 Python (Flask)

```python
import os
import secrets
from flask import Flask, redirect, request, session, jsonify
import requests

app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY')

VSRA_API = 'https://{接口网址}'
CLIENT_ID = os.environ.get('VSRA_CLIENT_ID')
CLIENT_SECRET = os.environ.get('VSRA_CLIENT_SECRET')
REDIRECT_URI = 'https://your-app.com/callback'

@app.route('/auth/vsra')
def auth_vsra():
    state = secrets.token_urlsafe(32)
    session['oauth_state'] = state

    auth_url = (
        f"{VSRA_API}/oauth/authorize?"
        f"client_id={CLIENT_ID}&"
        f"redirect_uri={REDIRECT_URI}&"
        f"scope=card&"
        f"state={state}"
    )
    return redirect(auth_url)

@app.route('/callback')
def callback():
    error = request.args.get('error')
    if error:
        return jsonify({'error': error}), 400

    code = request.args.get('code')
    state = request.args.get('state')

    if state != session.get('oauth_state'):
        return jsonify({'error': 'State mismatch'}), 400

    # 用 code 换取 token
    token_response = requests.post(f'{VSRA_API}/oauth/token', json={
        'grant_type': 'authorization_code',
        'code': code,
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
        'redirect_uri': REDIRECT_URI
    })

    if token_response.status_code != 200:
        return jsonify({'error': 'Token exchange failed'}), 500

    token_data = token_response.json()
    session['vsra_token'] = token_data

    return redirect('/dashboard')

@app.route('/api/cards')
def get_cards():
    token_data = session.get('vsra_token')
    if not token_data:
        return jsonify({'error': 'Not authenticated'}), 401

    response = requests.get(
        f'{VSRA_API}/oauth/api/cards',
        headers={'Authorization': f"Bearer {token_data['access_token']}"}
    )

    if response.status_code == 401:
        # 尝试刷新 token
        refresh_response = requests.post(f'{VSRA_API}/oauth/token', json={
            'grant_type': 'refresh_token',
            'refresh_token': token_data['refresh_token'],
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET
        })

        if refresh_response.status_code == 200:
            session['vsra_token'] = refresh_response.json()
            return get_cards()  # 重试
        else:
            return jsonify({'error': 'Please re-authenticate'}), 401

    return jsonify(response.json())

if __name__ == '__main__':
    app.run()
```

### 8.3 PHP (Laravel)

```php
<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

Route::get('/auth/vsra', function () {
    $state = Str::random(40);
    session(['oauth_state' => $state]);

    $query = http_build_query([
        'client_id' => config('services.vsra.client_id'),
        'redirect_uri' => config('services.vsra.redirect_uri'),
        'scope' => 'card',
        'state' => $state,
    ]);

    return redirect(config('services.vsra.api_url') . '/oauth/authorize?' . $query);
});

Route::get('/callback', function () {
    if (request('error')) {
        return response()->json(['error' => request('error')], 400);
    }

    if (request('state') !== session('oauth_state')) {
        return response()->json(['error' => 'State mismatch'], 400);
    }

    $response = Http::post(config('services.vsra.api_url') . '/oauth/token', [
        'grant_type' => 'authorization_code',
        'code' => request('code'),
        'client_id' => config('services.vsra.client_id'),
        'client_secret' => config('services.vsra.client_secret'),
        'redirect_uri' => config('services.vsra.redirect_uri'),
    ]);

    if ($response->failed()) {
        return response()->json(['error' => 'Token exchange failed'], 500);
    }

    session(['vsra_token' => $response->json()]);

    return redirect('/dashboard');
});

Route::get('/api/cards', function () {
    $token = session('vsra_token');
    if (!$token) {
        return response()->json(['error' => 'Not authenticated'], 401);
    }

    $response = Http::withToken($token['access_token'])
        ->get(config('services.vsra.api_url') . '/oauth/api/cards');

    if ($response->status() === 401) {
        // 尝试刷新
        $refreshResponse = Http::post(config('services.vsra.api_url') . '/oauth/token', [
            'grant_type' => 'refresh_token',
            'refresh_token' => $token['refresh_token'],
            'client_id' => config('services.vsra.client_id'),
            'client_secret' => config('services.vsra.client_secret'),
        ]);

        if ($refreshResponse->successful()) {
            session(['vsra_token' => $refreshResponse->json()]);
            return redirect('/api/cards');
        }

        return response()->json(['error' => 'Please re-authenticate'], 401);
    }

    return response()->json($response->json());
});
```

```php
<?php
// config/services.php
return [
    // ...
    'vsra' => [
        'api_url' => env('VSRA_API_URL', 'https://{接口网址}'),
        'client_id' => env('VSRA_CLIENT_ID'),
        'client_secret' => env('VSRA_CLIENT_SECRET'),
        'redirect_uri' => env('VSRA_REDIRECT_URI'),
    ],
];
```

---

## 9. 常见问题

### Q1: 授权码（code）有效期是多久？

授权码有效期为 **5 分钟**，且只能使用一次。请在获取 code 后尽快换取 token。

### Q2: access_token 和 refresh_token 的有效期是多久？

| Token         | 有效期 |
| ------------- | ------ |
| access_token  | 2 小时 |
| refresh_token | 30 天  |

### Q3: 用户取消授权后，之前的 token 还能用吗？

不能。用户取消授权后，所有已颁发的 token 将自动失效。

### Q4: 同一用户多次授权会获得不同的 openid 吗？

不会。同一用户在同一应用中的 openid 是固定的，多次授权获得的 openid 相同。

### Q5: 如何判断用户是否已绑定卡片？

调用 `/oauth/api/cards` 接口，检查返回的 `cards` 数组是否为空。

### Q6: redirect_uri 支持通配符吗？

不支持。redirect_uri 必须精确匹配注册时提供的地址。如需多个回调地址，请在注册时提供完整列表。

### Q7: 可以在纯前端应用（SPA）中使用吗？

可以，但需要注意：

-   授权流程需要后端参与（保护 client_secret）
-   推荐使用 BFF（Backend For Frontend）模式

### Q8: 如何处理"用户拒绝授权"的情况？

回调时检查 error 参数：

```javascript
const error = urlParams.get("error");
if (error === "access_denied") {
    // 显示友好提示，引导用户重试或使用其他方式
}
```

### Q9: 是否支持 PKCE？

当前版本暂不支持 PKCE。如有需要，请联系 VSRA 技术团队。

### Q10: 如何测试接入？

1. 申请测试环境凭证
2. 使用测试环境 API：`https://api-staging.vsra.com`
3. 测试账号可联系 VSRA 运营团队获取

---

## 10. 附录

### A. 完整错误码列表

| HTTP Status | error                  | 说明                       |
| ----------- | ---------------------- | -------------------------- |
| 400         | invalid_request        | 请求参数缺失或格式错误     |
| 400         | invalid_grant          | 授权码无效、已过期或已使用 |
| 400         | invalid_scope          | 请求的 scope 无效          |
| 400         | unsupported_grant_type | 不支持的授权类型           |
| 401         | invalid_client         | 应用凭证无效               |
| 401         | unauthorized           | Token 无效或已过期         |
| 401         | unauthorized_client    | redirect_uri 不在白名单    |
| 403         | access_denied          | 用户拒绝授权               |
| 403         | insufficient_scope     | Token 权限不足             |
| 404         | not_found              | 资源不存在                 |
| 400         | encryption_not_configured | 应用未配置 RSA 公钥     |
| 500         | encryption_failed      | 数据加密失败               |

### B. Scope 权限矩阵

| API                            | base | profile | card | eligibility | RSA公钥 |
| ------------------------------ | ---- | ------- | ---- | ----------- | ------- |
| /oauth/api/userinfo            | ✓    | ✓       | ✓    | ✓           | -       |
| /oauth/api/profile             | ✗    | ✓       | ✓    | ✓           | -       |
| /oauth/api/profile/sensitive   | ✗    | ✓       | ✓    | ✓           | 必须    |
| /oauth/api/cards               | ✗    | ✗       | ✓    | ✓           | -       |
| /oauth/api/cards/sensitive     | ✗    | ✗       | ✓    | ✓           | 必须    |
| /oauth/api/memberships         | ✗    | ✗       | ✓    | ✓           | -       |
| /oauth/api/eligibility/{oid}   | ✗    | ✗       | ✗    | ✓           | -       |

### C. 卡片等级对照表

| product_level           | 中文名称   | 英文名称           |
| ----------------------- | ---------- | ------------------ |
| Visa Classic            | 普卡       | Classic            |
| Visa Gold               | 金卡       | Gold               |
| Visa Platinum           | 白金卡     | Platinum           |
| Visa Signature          | 御玺卡     | Signature          |
| Visa Signature Business | 御玺商务卡 | Signature Business |
| Visa Infinite           | 无限卡     | Infinite           |
| Visa Business           | 商务卡     | Business           |
| Visa Business Enhanced  | 商务增强卡 | Business Enhanced  |

### D. 联系方式

-   **技术支持**：tech-support@vsra.com
-   **商务合作**：business@vsra.com
-   **API 状态页**：https://status.vsra.com

---

## 更新日志

| 版本 | 日期       | 更新内容                                    |
| ---- | ---------- | ------------------------------------------- |
| v1.1 | 2025-01-30 | 新增敏感数据加密接口（profile/sensitive, cards/sensitive） |
| v1.0 | 2025-01-30 | 初始版本                                    |

---

_© 2025 Visa Select Rewards App. All rights reserved._
