You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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" }

我有以下疑问:

  1. 该错误是否仅因Apple授权码的单次使用特性导致?
  2. token请求的配置(如client_secret生成、redirect_uri等)错误是否会引发此错误?
  3. 如何确保授权码仅被使用一次并正确获取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,提问作者이윤호

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 03:59:51