188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
// ============================================================
|
||
// 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<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 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<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'));
|
||
});
|
||
});
|
||
}
|
||
}
|