- Split AuthService into AppleAuthService, TokenService, AuthService - Add dev-login endpoint (dev-only, disabled in production) - AppleLoginDto: authorizationCode optional, add userIdentifier/email/fullName/nonce - Login/refresh responses now include user object - logout: single-token revoke + JwtAuthGuard protection - users.repository: switch from in-memory Map to Prisma persistence - JWT payload includes role, guards attach full user info to request - Dual JWT secret support (JWT_ACCESS_SECRET / JWT_REFRESH_SECRET) - Replace jwks-rsa+jsonwebtoken with jose library - Prisma User model: add role field - Independent DTO files with @Transform for empty string safety - Add 5 iOS login flow documentation files
80 lines
2.3 KiB
TypeScript
80 lines
2.3 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
|
|
@Injectable()
|
|
export class AppleAuthService {
|
|
private readonly appleIssuer: string;
|
|
private readonly appleBundleId: string;
|
|
private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
|
|
|
|
constructor(private readonly configService: ConfigService) {
|
|
this.appleIssuer = this.configService.get<string>(
|
|
'apple.issuer',
|
|
'https://appleid.apple.com',
|
|
);
|
|
this.appleBundleId = this.configService.get<string>('apple.bundleId', '');
|
|
this.jwks = createRemoteJWKSet(
|
|
new URL('https://appleid.apple.com/auth/keys'),
|
|
);
|
|
}
|
|
|
|
async verifyIdentityToken(identityToken: string): Promise<{
|
|
appleUserId: string;
|
|
email?: string;
|
|
emailVerified?: boolean;
|
|
}> {
|
|
if (!this.appleBundleId) {
|
|
return this.verifyMock(identityToken);
|
|
}
|
|
return this.verifyReal(identityToken);
|
|
}
|
|
|
|
private verifyMock(identityToken: string): {
|
|
appleUserId: string;
|
|
} {
|
|
if (!identityToken || identityToken.trim().length < 4) {
|
|
throw new UnauthorizedException('identityToken 无效');
|
|
}
|
|
return {
|
|
appleUserId: crypto
|
|
.createHash('sha256')
|
|
.update(`apple-mock:${identityToken}`)
|
|
.digest('hex')
|
|
.slice(0, 64),
|
|
};
|
|
}
|
|
|
|
private async verifyReal(identityToken: string): Promise<{
|
|
appleUserId: string;
|
|
email?: string;
|
|
emailVerified?: boolean;
|
|
}> {
|
|
try {
|
|
const { payload } = await jwtVerify(identityToken, this.jwks, {
|
|
issuer: this.appleIssuer,
|
|
audience: this.appleBundleId,
|
|
});
|
|
|
|
return {
|
|
appleUserId: payload.sub!,
|
|
email:
|
|
typeof payload.email === 'string' ? payload.email : undefined,
|
|
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
|
|
};
|
|
} catch (err: any) {
|
|
const msg: string = err?.message ?? '';
|
|
if (msg.includes('audience')) {
|
|
throw new UnauthorizedException(
|
|
`identityToken audience 不匹配,期望 ${this.appleBundleId}`,
|
|
);
|
|
}
|
|
if (msg.includes('issuer')) {
|
|
throw new UnauthorizedException('identityToken issuer 无效');
|
|
}
|
|
throw new UnauthorizedException('identityToken 验证失败');
|
|
}
|
|
}
|
|
}
|