// ============================================================ // AppleProvider — Apple Sign In Identity Token 验证 // // ── Flutter sign_in_with_apple 流程 ───────────────────────── // 1. 客户端调用 SignInWithApple.getAppleIDCredential(...) // 2. 用户在 Apple 身份验证界面完成授权 // 3. 获取 AppleIDCredential.identityToken(JWT 字符串) // + AppleIDCredential.userIdentifier(user ID,18-20位字母数字) // 4. 将 identityToken 发送到 POST /api/v1/auth/apple // // ── Identity Token 验证流程 ───────────────────────────────── // Apple Identity Token 是 ES256(ECDSA P-256 SHA-256)签名的 JWT // // Step 1: 解析 JWT header,获取 kid(密钥 ID) // Step 2: 获取 Apple 公钥集合(JWKS) // GET https://appleid.apple.com/auth/keys // Step 3: 找到 kid 匹配的公钥(JWK 格式转 PEM) // Step 4: 验证 JWT 签名(crypto.createVerify('SHA256')) // Step 5: 验证 claims: // - iss = 'https://appleid.apple.com' // - aud = APPLE_CLIENT_ID (Bundle ID or Service ID) // - exp > now(未过期) // // ── 申请 Apple Sign In 步骤 ──────────────────────────────── // 1. Apple Developer 账号(个人 $99/年) // https://developer.apple.com // // 2. 开启 Sign In with Apple 能力 // Certificates, Identifiers & Profiles // → Identifiers → 选择 App ID (cn.gogenex.consumer) // → Capabilities → 勾选「Sign In with Apple」 // // 3. iOS 项目配置(Xcode) // Target → Signing & Capabilities → + Capability → Sign In with Apple // // 4. 环境变量 // APPLE_CLIENT_ID — Bundle ID (cn.gogenex.consumer) 或 Service ID // // ── 注意事项 ─────────────────────────────────────────────── // - Apple 首次登录会提供 email,之后登录不再提供(需客户端缓存或提示用户输入) // - userIdentifier(user ID)是稳定的,可跨设备识别同一用户 // - JWKS 可缓存(Cache-Control),无需每次请求 // ============================================================ import { Injectable, Logger, UnauthorizedException, } from '@nestjs/common'; import * as https from 'https'; import * as crypto from 'crypto'; /** Apple JWKS 公钥 */ interface AppleJWK { kty: string; kid: string; use: string; alg: string; n: string; // RSA modulus(Base64URL) e: string; // RSA exponent(Base64URL) } interface AppleJWKS { keys: AppleJWK[]; } /** Apple Identity Token 验证后的用户信息 */ export interface AppleUserInfo { sub: string; // user identifier(稳定唯一 ID) email?: string; // 仅首次登录时返回 emailVerified?: boolean; isPrivateEmail?: boolean; // Apple 隐藏真实邮箱时为 true } @Injectable() export class AppleProvider { private readonly logger = new Logger('AppleProvider'); private readonly clientId = process.env.APPLE_CLIENT_ID || 'cn.gogenex.consumer'; // 简单内存缓存 JWKS(Apple 公钥极少变化) private cachedJwks: AppleJWK[] | null = null; private jwksCachedAt = 0; private readonly JWKS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时 /** * 验证 Apple Identity Token 并返回用户信息 * * @param identityToken Flutter sign_in_with_apple 返回的 JWT */ async verifyIdentityToken(identityToken: string): Promise { // 1. 解析 JWT(不验证签名,只读 header + payload) const parts = identityToken.split('.'); if (parts.length !== 3) { throw new UnauthorizedException('Apple Identity Token 格式无效'); } const header = JSON.parse(this.base64urlDecode(parts[0])); const payload = JSON.parse(this.base64urlDecode(parts[1])); // 2. 基本 claims 验证 const now = Math.floor(Date.now() / 1000); if (payload.exp < now) { throw new UnauthorizedException('Apple Identity Token 已过期'); } if (payload.iss !== 'https://appleid.apple.com') { throw new UnauthorizedException('Apple Identity Token issuer 无效'); } if (payload.aud !== this.clientId) { this.logger.error(`Apple token aud mismatch: ${payload.aud} != ${this.clientId}`); throw new UnauthorizedException('Apple Token 不属于此应用'); } // 3. 获取 Apple 公钥 + 验证签名 const jwks = await this.getAppleJWKS(); const matchingKey = jwks.find((k) => k.kid === header.kid); if (!matchingKey) { throw new UnauthorizedException(`未找到 Apple 公钥 (kid=${header.kid})`); } const publicKeyPem = this.jwkToPublicKeyPem(matchingKey); const signedData = `${parts[0]}.${parts[1]}`; const signature = Buffer.from(parts[2], 'base64url'); const verifier = crypto.createVerify('SHA256'); verifier.update(signedData); const valid = verifier.verify(publicKeyPem, signature); if (!valid) { throw new UnauthorizedException('Apple Identity Token 签名验证失败'); } this.logger.log(`Apple token verified: sub=${payload.sub?.slice(0, 6)}...`); return { sub: payload.sub, email: payload.email, emailVerified: payload.email_verified === 'true' || payload.email_verified === true, isPrivateEmail: payload.is_private_email === 'true' || payload.is_private_email === true, }; } /** 获取 Apple JWKS 公钥集合(带内存缓存) */ private async getAppleJWKS(): Promise { const now = Date.now(); if (this.cachedJwks && now - this.jwksCachedAt < this.JWKS_CACHE_TTL) { return this.cachedJwks; } const data = await this.httpsGet('https://appleid.apple.com/auth/keys'); const jwks = JSON.parse(data) as AppleJWKS; this.cachedJwks = jwks.keys; this.jwksCachedAt = now; return jwks.keys; } /** * 将 Apple JWK(RSA 公钥)转换为 PEM 格式 * Apple 使用 RS256(RSA-SHA256),JWK 包含 n(modulus)和 e(exponent) */ private jwkToPublicKeyPem(jwk: AppleJWK): string { // Node.js 18+ 支持直接从 JWK 创建 KeyObject const key = crypto.createPublicKey({ key: jwk as any, format: 'jwk' }); return key.export({ type: 'spki', format: 'pem' }) as string; } /** Base64URL → UTF-8 字符串 */ private base64urlDecode(input: string): string { const base64 = input.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); return Buffer.from(padded, 'base64').toString('utf8'); } private httpsGet(url: string): Promise { return new Promise((resolve, reject) => { const req = https.get(url, (res) => { let body = ''; res.on('data', (chunk) => (body += chunk)); res.on('end', () => resolve(body)); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('Apple JWKS API timeout')); }); }); } }