gcx/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts

188 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// AppleProvider — Apple Sign In Identity Token 验证
//
// ── Flutter sign_in_with_apple 流程 ─────────────────────────
// 1. 客户端调用 SignInWithApple.getAppleIDCredential(...)
// 2. 用户在 Apple 身份验证界面完成授权
// 3. 获取 AppleIDCredential.identityTokenJWT 字符串)
// + AppleIDCredential.userIdentifieruser ID18-20位字母数字
// 4. 将 identityToken 发送到 POST /api/v1/auth/apple
//
// ── Identity Token 验证流程 ─────────────────────────────────
// Apple Identity Token 是 ES256ECDSA 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之后登录不再提供需客户端缓存或提示用户输入
// - userIdentifieruser 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 modulusBase64URL
e: string; // RSA exponentBase64URL
}
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';
// 简单内存缓存 JWKSApple 公钥极少变化)
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<AppleUserInfo> {
// 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<AppleJWK[]> {
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 JWKRSA 公钥)转换为 PEM 格式
* Apple 使用 RS256RSA-SHA256JWK 包含 nmodulus和 eexponent
*/
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<string> {
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'));
});
});
}
}