NestJS集成Apple Sign-In遇invalid_grant错误:授权码已使用
Apple Sign-In集成问题:
invalid_grant: The code has already been used 我在NestJS应用中使用passport-apple策略集成Apple Sign-In,从请求体提取授权码后,通过axios向Apple的token端点请求id_token时,遇到以下错误:
{ "error": "invalid_grant", "error_description": "The code has already been used." }
我的AppleStrategy实现代码
import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-apple'; import { AuthService } from '../auth.service'; import { MemberService } from 'src/mvc/member/member.service'; import { Request } from 'express'; import axios from 'axios'; import * as jwt from 'jsonwebtoken'; @Injectable() export class AppleStrategy extends PassportStrategy(Strategy, 'apple') { constructor( private readonly authService: AuthService, private readonly memberService: MemberService, ) { super({ clientID: process.env.APPLE_CLIENT_ID, teamID: process.env.APPLE_TEAM_ID, keyID: process.env.APPLE_KEY_ID, privateKeyString: process.env.APPLE_PRIVATE_KEY.replace(/\\n/g, '\n'), callbackURL: process.env.APPLE_CALLBACK_URL?.trim().replace(/\/$/, ''), scope: ['openid', 'email', 'name'], response_type: 'code id_token', response_mode: 'form_post', passReqToCallback: true, usePKCE: true, }); } async validate( req: Request, accessToken: string, refreshToken: string, profile: any, done: CallableFunction, ): Promise<any> { try { console.log('req.body:', req.body); const { code } = req.body; if (!code) { throw new Error('No authorization code provided'); } const tokenResponse = await this.requestAppleToken(code); console.log('tokenResponse:', tokenResponse.data); const { id_token } = tokenResponse.data; if (!id_token) { throw new Error('id_token not received from Apple'); } const decoded = this.validateIdToken(id_token); console.log('decoded id_token:', decoded); const user = await this.processUser(decoded); return done(null, user); } catch (error) { this.handleAppleError(error); return done(error, null); } } private generateAppleClientSecret(): string { const now = Math.floor(Date.now() / 1000); return jwt.sign( { iss: process.env.APPLE_TEAM_ID, iat: now, exp: now + 15777000, // ~6 months aud: 'https://appleid.apple.com', sub: process.env.APPLE_CLIENT_ID, }, process.env.APPLE_PRIVATE_KEY.replace(/\\n/g, '\n'), { algorithm: 'ES256', keyid: process.env.APPLE_KEY_ID, } ); } private async requestAppleToken(code: string) { const tokenData = new URLSearchParams({ client_id: process.env.APPLE_CLIENT_ID, client_secret: this.generateAppleClientSecret(), code, grant_type: 'authorization_code', redirect_uri: process.env.APPLE_CALLBACK_URL, }); return axios.post('https://appleid.apple.com/auth/token', tokenData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout: 10000, }); } private validateIdToken(idToken: string) { const decoded = jwt.decode(idToken); if (!decoded || !decoded.sub) { throw new Error('Invalid id_token'); } return decoded; } private async processUser(decoded: any) { const user = { mb_id: '', mb_name: '', mb_social_id: decoded.sub, mb_vendor: 'apple', }; const member = await this.authService.validateSNSMember(user); return member || this.memberService.createSNSMember(user); } private handleAppleError(error: any) { console.error('Apple Auth Error:', error); } }
额外信息
请求体中的授权码示例:
{ "state": "7603addc08", "code": "ca8b79f0c6532476a9346227cf357da5e.0.ssqty.UlTkJ9xLcTpidvUSrmwoBw" }
我有以下疑问:
- 该错误是否仅因Apple授权码的单次使用特性导致?
- token请求的配置(如client_secret生成、redirect_uri等)错误是否会引发此错误?
- 如何确保授权码仅被使用一次并正确获取id_token?
问题分析与解决方案
1. 该错误是否仅因Apple授权码的单次使用特性导致?
不是。虽然Apple授权码确实只能使用一次,但这个错误也可能由其他配置问题触发,比如重复请求、redirect_uri不匹配、client_secret无效等,这些情况都会让Apple服务器判定授权码已被使用或无效。
2. token请求的配置错误是否会引发此错误?
会。以下配置错误都可能导致这个错误:
- redirect_uri不匹配:请求token时的
redirect_uri必须和授权请求时的callbackURL完全一致(包括末尾是否有斜杠、大小写)。你的代码中初始化Strategy时对callbackURL做了trim().replace(/\/$/, '')处理,但requestAppleToken里直接用process.env.APPLE_CALLBACK_URL,如果环境变量里的URL末尾有斜杠,就会导致不匹配。 - client_secret无效或错误:比如签名算法不对(必须是ES256)、系统时间偏差过大导致过期时间校验失败、iss/sub/keyID配置错误,都会让Apple拒绝请求,返回类似的错误。
- 重复请求:
passport-apple底层已经自动完成了token请求和profile解析,你手动再调用requestAppleToken会导致同一个授权码被请求两次,直接触发错误。
3. 如何确保授权码仅被使用一次并正确获取id_token?
针对你的代码,做以下修改和优化:
(1)移除重复的token请求逻辑
passport-apple的Strategy在验证流程中已经自动完成了token请求、id_token解析和profile生成,你不需要手动调用requestAppleToken。validate方法中拿到的profile已经包含用户唯一标识id(对应Apple的sub字段)、email、name等信息。
(2)统一redirect_uri配置
确保请求token时的redirect_uri和初始化Strategy时的callbackURL完全一致:
private async requestAppleToken(code: string) { // 使用和Strategy初始化时一致的处理逻辑 const callbackURL = process.env.APPLE_CALLBACK_URL?.trim().replace(/\/$/, ''); const tokenData = new URLSearchParams({ client_id: process.env.APPLE_CLIENT_ID, client_secret: this.generateAppleClientSecret(), code, grant_type: 'authorization_code', redirect_uri: callbackURL, }); return axios.post('https://appleid.apple.com/auth/token', tokenData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout: 10000, }); }
(3)优化client_secret生成
缩短client_secret的过期时间(建议1小时,减少泄露风险),同时确保参数正确:
private generateAppleClientSecret(): string { const now = Math.floor(Date.now() / 1000); return jwt.sign( { iss: process.env.APPLE_TEAM_ID, iat: now, exp: now + 3600, // 1小时 aud: 'https://appleid.apple.com', sub: process.env.APPLE_CLIENT_ID, }, process.env.APPLE_PRIVATE_KEY.replace(/\\n/g, '\n'), { algorithm: 'ES256', keyid: process.env.APPLE_KEY_ID, } ); }
(4)简化validate方法
直接使用passport-apple返回的profile数据:
async validate( req: Request, accessToken: string, refreshToken: string, profile: any, done: CallableFunction, ): Promise<any> { try { const user = { mb_id: '', mb_name: profile.name?.givenName || '', mb_social_id: profile.id, mb_vendor: 'apple', }; const member = await this.authService.validateSNSMember(user); return done(null, member || this.memberService.createSNSMember(user)); } catch (error) { console.error('Apple Auth Error:', error); return done(error, null); } }
(5)添加日志排查
在关键步骤添加日志,确认授权码只被使用一次,并且配置参数正确:
- 记录每次请求的授权码,避免重复使用
- 解码client_secret检查参数是否正确(可使用本地工具,不要泄露密钥)
内容的提问来源于stack exchange,提问作者이윤호
相关产品推荐
相关产品推荐

