# iOS 登录流程 —— Apple 登录详解

---

## 一、Apple 登录核心理解

**后端不需要 Apple 开发证书。** Apple 登录的公钥是 Apple 公开提供的 JWKS 地址，后端运行时获取即可。

```
iOS 真机运行：    需要 Apple 开发证书 + Provisioning Profile
后端验证 Apple 登录：  不需要证书，只需要 Apple JWKS 公钥 + Bundle ID
```

---

## 二、环境变量配置

```env
JWT_ACCESS_SECRET=你自己的随机强密钥
JWT_REFRESH_SECRET=你自己的随机强密钥
APPLE_BUNDLE_ID=cloud.longde.AIStudyApp
APPLE_ISSUER=https://appleid.apple.com
APPLE_JWKS_URL=https://appleid.apple.com/auth/keys
```

---

## 三、Apple 登录流程

### iOS 端

iOS 通过 Sign in with Apple 拿到以下数据：

```
identityToken          ← JWT，唯一必须的值
authorizationCode      ← 可选，后面可能用于完整校验/撤销
userIdentifier         ← 可选，辅助识别，但后端不要完全信任
email                  ← 可选，注意：仅首次授权时返回
fullName               ← 可选，注意：仅首次授权时返回
```

### 发给后端

```http
POST /api/auth/apple
```

```json
{
  "identityToken": "eyJ...",
  "authorizationCode": "c123...",
  "userIdentifier": "000123.xxxxx",
  "email": "xxx@privaterelay.appleid.com",
  "fullName": {
    "givenName": "Long",
    "familyName": "De"
  }
}
```

最小必填字段只有：

```json
{
  "identityToken": "eyJ..."
}
```

---

## 四、Apple Token 校验（核心）

使用 `jose` 库，不要手写公钥解析：

```bash
npm install jose
```

### AppleAuthService 实现

```ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify } from 'jose';

@Injectable()
export class AppleAuthService {
  private readonly appleIssuer = 'https://appleid.apple.com';
  private readonly appleBundleId = process.env.APPLE_BUNDLE_ID!;
  private readonly jwks = createRemoteJWKSet(
    new URL('https://appleid.apple.com/auth/keys'),
  );

  async verifyIdentityToken(identityToken: string) {
    try {
      const { payload } = await jwtVerify(identityToken, this.jwks, {
        issuer: this.appleIssuer,
        audience: this.appleBundleId,
      });

      return {
        appleUserId: payload.sub,        // ← Apple 用户唯一 ID，后端核心信任字段
        email: typeof payload.email === 'string' ? payload.email : undefined,
        emailVerified: payload.email_verified,
      };
    } catch (error) {
      throw new UnauthorizedException('Invalid Apple identity token');
    }
  }
}
```

### jose 自动完成的工作

```
1. 读取 JWT header 里的 kid
2. 请求 Apple JWKS 地址，找到 kid 对应的公钥
3. 验证 JWT 签名（RSA）
4. 校验 issuer === https://appleid.apple.com
5. 校验 audience === cloud.longde.AIStudyApp
6. 校验 exp 过期时间
```

你不需要手动把 `n`、`e` 转成 RSA 公钥。

---

## 五、Apple Login 接口实现

### Controller

```ts
@Post('apple')
async loginWithApple(@Body() dto: AppleLoginDto) {
  const appleUser = await this.appleAuthService.verifyIdentityToken(
    dto.identityToken,
  );

  return this.authService.loginWithProvider({
    provider: 'APPLE',
    providerUserId: appleUser.appleUserId,  // ← 即 identityToken.sub
    email: appleUser.email ?? dto.email,
    nickname: dto.fullName?.givenName
      ? `${dto.fullName.givenName} ${dto.fullName.familyName ?? ''}`
      : undefined,
  });
}
```

### DTO

```ts
export class AppleLoginDto {
  @IsString()
  @IsNotEmpty()
  identityToken: string;

  @IsOptional()
  @IsString()
  authorizationCode?: string;

  @IsOptional()
  @IsString()
  userIdentifier?: string;

  @IsOptional()
  @IsEmail()
  email?: string;

  @IsOptional()
  fullName?: {
    givenName?: string;
    familyName?: string;
  };
}
```

---

## 六、后端信任模型

| 字段 | 信任级别 | 说明 |
|------|----------|------|
| `identityToken.sub` | ✅ 信任 | Apple 签名验证通过后的用户唯一 ID |
| `identityToken.aud` | ✅ 信任 | 必须等于你的 Bundle ID |
| `identityToken.iss` | ✅ 信任 | 必须等于 `https://appleid.apple.com` |
| `identityToken.email` | ⚠️ 参考 | Apple 侧校验过的邮箱，但可能为空 |
| `userIdentifier`（请求体） | ❌ 不信任 | iOS 侧传的，可能被篡改 |
| `email`（请求体） | ❌ 不信任 | iOS 侧传的，可能被篡改 |
| `userId`（请求体） | ❌ 绝对不能信 | 用户 ID 只能从后端 JWT 获取 |

**核心规则**：

```
1. 后端只信 identityToken 里校验出来的 sub
2. 用 sub 去 auth_accounts(provider=APPLE, providerUserId=sub) 查找/创建用户
3. 不要信前端传的 userIdentifier / email / name 作为唯一标识
4. 绝对不要让前端传 userId
```

---

## 七、Apple 登录的特别注意事项

### 1. 电子邮件和姓名仅首次返回

```
email / fullName 只在用户第一次授权时返回
第二次及以后登录，Apple 不会返回这两个字段
```

所以首次登录时需要将 email 和姓名保存到 `auth_accounts` 和 `users` 表中。

### 2. Apple 私密邮箱

Apple 可能返回 `xxx@privaterelay.appleid.com` 格式的私密中继邮箱，这是正常的。如果用户选择隐藏邮箱，Apple 会生成一个中转邮箱，发到该邮箱的邮件会自动转发到用户真实邮箱。

### 3. 什么时候后端才需要 Apple Key？

只有在后端要主动调用 Apple 服务时才需要 `.p8` 私钥：

- App Store Server API
- App Store Connect API
- 订阅状态查询
- IAP 交易验证
- APNs 推送

**登录不需要这些，这些都是后面的事情。**

---

## 八、完整后端校验小结

```text
                          POST /api/auth/apple
                                │
                                ▼
┌─────────────────────────────────────────────────────┐
│  1. 拿到 identityToken                              │
│  2. 解析 header 里的 kid                             │
│  3. 请求 Apple JWKS → https://appleid.apple.com/auth/keys
│  4. 找到 kid 对应的公钥                               │
│  5. 验证 JWT 签名                                    │
│  6. 校验 iss === https://appleid.apple.com           │
│  7. 校验 aud === cloud.longde.AIStudyApp             │
│  8. 校验 exp 未过期                                  │
│  9. 取 sub 作为 Apple 用户唯一 ID                    │
│  10. 查 auth_accounts(provider=APPLE, providerUserId=sub)│
│  11. 不存在→创建 User + AuthAccount                   │
│  12. 存在→找到对应 User                              │
│  13. 生成 accessToken + refreshToken                 │
│  14. refreshToken hash 入库                          │
│  15. 返回 { accessToken, refreshToken, user }       │
└─────────────────────────────────────────────────────┘
```
