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

Delphi12实现FCM推送服务遇JWT时间校验错误排查

解决Delphi JOSE库生成FCM JWT时的"invalid_grant"时间错误问题

我用Delphi 12开发Windows服务端,给FireMonkey Android应用的特定用户发送FCM推送。移动端已完成测试,能正常连接Firebase并获取正确的DeviceID和Firebase令牌。服务端采用Delphi JOSE Library实现FCM推送,但运行时返回invalid_grant错误,提示JWT令牌时间范围不合理,尽管日志显示时间值看似正确。

移动端FireMonkey代码

var
   PushService: TPushService;
   ServiceConnection: TPushServiceConnection;
   Notifications: TArray<TPushServiceNotification>;
begin

 PushService :=
 TPushServiceManager.Instance.GetServiceByName(TPushService.TServiceNames.FCM);
   ServiceConnection := TPushServiceConnection.Create(PushService);
   ServiceConnection.Active := True;
   ServiceConnection.OnChange := OnServiceConnectionChange;
   ServiceConnection.OnReceiveNotification := OnReceiveNotificationEvent;

   FDeviceId :=
 PushService.DeviceIDValue[TPushService.TDeviceIDNames.DeviceId];
   MemoLog.Lines.Add('DeviceID: ' + FDeviceId);
   MemoLog.Lines.Add('Ready to receive!');

 FDeviceToken := PushService.DeviceTokenValue[TPushService.TDeviceTokenNames.DeviceToken];

   // Checks notification on startup, if application was launched from cold start
   // by tapping on Notification in Notification Center
   Notifications := PushService.StartupNotifications;
   if Length(Notifications) > 0 then
   begin
       MemoLog.Lines.Add('-----------------------------------------');
       MemoLog.Lines.Add('DataKey = ' + Notifications[0].DataKey);
       MemoLog.Lines.Add('Json = ' + Notifications[0].Json.ToString);
       MemoLog.Lines.Add('DataObject = ' +
 Notifications[0].DataObject.ToString);
       MemoLog.Lines.Add('-----------------------------------------');
   end;
end;

服务端Delphi代码

uses   JOSE.Core.JWT, JOSE.Core.JWS, JOSE.Types.JSON, JOSE.Types.Bytes, JOSE.Types.Utils, JOSE.Core.Base, JOSE.Encoding.Base64,
  Vcl.StdCtrls, JOSE.Core.JWK,JOSE.Core.JWA;


procedure SendPushViaFCM(const ServiceAccountPath, DeviceToken, Title, Body: string; Log: TStrings);
var
  ServiceJSON: TJSONObject;
  PrivateKey, ClientEmail, ProjectID, JWT, AccessToken: string;
  JWS: TJWS;
  JWTHeader: TJWT;
  HTTP: TNetHTTPClient;
  Req: TNetHTTPRequest;
  TokenResponse: TJSONObject;
  Payload, Notification: TJSONObject;
  Response: IHTTPResponse;
  JWTBody: TJWTClaims;
  Stream: TStringStream;

  JWK: TJWK;
  KeyBytes: TJOSEBytes;

  UnixNow: Int64;
  const
  SkewSeconds = 0; // 1 minuto di margine per sicurezza
begin

  UnixNow := DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now))-SkewSeconds;

  ServiceJSON := TJSONObject.ParseJSONValue(TFile.ReadAllText(ServiceAccountPath)) as TJSONObject;
  try
      ClientEmail := ServiceJSON.GetValue<string>('client_email');
      ProjectID := ServiceJSON.GetValue<string>('project_id');

      PrivateKey := ServiceJSON.GetValue<string>('private_key');
      PrivateKey := StringReplace(PrivateKey, '\n', sLineBreak, [rfReplaceAll]);
      PrivateKey := StringReplace(PrivateKey, '\\n', sLineBreak, [rfReplaceAll]);

      Log.Add('Chiave privata finale:');
      Log.Add(PrivateKey);

      JWTHeader := TJWT.Create;
      try
        UnixNow := DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now));

        JWTHeader.Claims.Issuer := ClientEmail;
        JWTHeader.Claims.Audience := 'https://oauth2.googleapis.com/token';
        JWTHeader.Claims.IssuedAt := UnixToDateTime(UnixNow);
        JWTHeader.Claims.Expiration := UnixToDateTime(UnixNow + 3600);
        JWTHeader.Claims.SetClaimOfType<string>('scope', 'https://www.googleapis.com/auth/firebase.messaging');



        JWK := TJWK.Create(TEncoding.UTF8.GetBytes(PrivateKey));
        JWS := TJWS.Create(JWTHeader);



        try
          JWS.Sign(JWK, TJOSEAlgorithmId.RS256);
          JWT := JWS.CompactToken;
          Log.Add('JWT generato:');
          Log.Add(JWT);
        finally
          JWS.Free;
        end;
      finally
        JWTHeader.Free;
      end;


      Log.Add('UNIX iat: ' + UnixNow.ToString);           // Es: 1744715156
      Log.Add('UTC iat:  ' + DateTimeToStr(UnixToDateTime(UnixNow))); 
   
      Log.Add( 'Local now: ' + DateTimeToStr( Now ) );
      Log.Add( 'UTC now:   ' + DateTimeToStr( TTimeZone.Local.ToUniversalTime( Now ) ) );
      Log.Add( 'UnixNow:   ' + UnixNow.ToString );

      HTTP := TNetHTTPClient.Create(nil);
      Req := TNetHTTPRequest.Create(nil);
    try
      Req.Client := HTTP;
      Stream := TStringStream.Create('grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=' + JWT, TEncoding.UTF8);
      try
        Req.CustomHeaders['Content-Type'] := 'application/x-www-form-urlencoded';
        Response := Req.Post('https://oauth2.googleapis.com/token', Stream);

        Log.Add('Risposta token OAuth:');
        Log.Add(Response.ContentAsString(TEncoding.UTF8));

        TokenResponse := TJSONObject.ParseJSONValue(Response.ContentAsString) as TJSONObject;
        if TokenResponse.TryGetValue<string>('access_token', AccessToken) then
          Log.Add('Access token ottenuto.')
        else
          raise Exception.Create('Errore: access_token non trovato. Controlla il JWT o i dati del file JSON.');

      finally
        Stream.Free;
      end;
    finally
      Req.Free;
      HTTP.Free;
    end;



    HTTP := TNetHTTPClient.Create(nil);
    Req := TNetHTTPRequest.Create(nil);
    try
      Req.Client := HTTP;
      Req.CustomHeaders['Authorization'] := 'Bearer ' + AccessToken;
      Req.CustomHeaders['Content-Type'] := 'application/json';

      Payload := TJSONObject.Create;
      try
        Notification := TJSONObject.Create;
        Notification.AddPair('title', Title);
        Notification.AddPair('body', Body);

        Payload.AddPair('message', TJSONObject.Create
          .AddPair('token', DeviceToken)
          .AddPair('notification', Notification));

        Stream := TStringStream.Create(Payload.ToString, TEncoding.UTF8);
        try
          Response := Req.Post(Format('https://fcm.googleapis.com/v1/projects/%s/messages:send', [ProjectID]), Stream);
          Log.Add('Status: ' + Response.StatusCode.ToString);
          Log.Add('Response: ' + Response.ContentAsString);
        finally
          Stream.Free;
        end;
      finally
        Payload.Free;
      end;
    finally
      Req.Free;
      HTTP.Free;
    end;

  finally
    ServiceJSON.Free;
  end;
end;

错误日志

UNIX iat: 1744725745 UTC iat: 15/04/2025 14:02:25 Local now:
15/04/2025 16:02:26 UTC now: 15/04/2025 14:02:26 UnixNow:

1744725745

OAuth: {"error":"invalid_grant","error_description":"Invalid JWT:
Token must be a short-lived token (60 minutes) and in a reasonable
timeframe. Check your iat and exp values in the JWT claim."} Errore:
access_token non trovato. Controlla il JWT o i dati del file JSON.

解决方案

问题根源

代码中设置JWT的IssuedAt和Expiration时,错误地使用UnixToDateTime将Unix时间戳转换为TDateTime类型,但Delphi JOSE库的Claims.IssuedAt和Claims.Expiration字段要求直接传入Int64类型的Unix时间戳,而非TDateTime。这导致JOSE库内部解析时间时出现偏差,生成的JWT中iat和exp字段不符合Google OAuth服务器的验证规则。

修复后的关键代码

修改服务端JWT声明部分的时间赋值逻辑:

JWTHeader := TJWT.Create;
try
  // 仅计算一次当前UTC时间的Unix戳,避免重复计算导致的微小时间差
  UnixNow := DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now));

  JWTHeader.Claims.Issuer := ClientEmail;
  JWTHeader.Claims.Audience := 'https://oauth2.googleapis.com/token';
  // 直接传入Unix时间戳,无需转换为TDateTime
  JWTHeader.Claims.IssuedAt := UnixNow;
  // 设置有效期为1小时,符合Google OAuth的要求(最大不超过3600秒)
  JWTHeader.Claims.Expiration := UnixNow + 3600;
  JWTHeader.Claims.SetClaimOfType<string>('scope', 'https://www.googleapis.com/auth/firebase.messaging');

  // 后续签名逻辑保持不变
  JWK := TJWK.Create(TEncoding.UTF8.GetBytes(PrivateKey));
  JWS := TJWS.Create(JWTHeader);
  try
    JWS.Sign(JWK, TJOSEAlgorithmId.RS256);
    JWT := JWS.CompactToken;
    Log.Add('JWT generato:');
    Log.Add(JWT);
  finally
    JWS.Free;
  end;
finally
  JWTHeader.Free;
end;

额外优化建议

  • 删除代码开头未被使用的UnixNow计算语句(UnixNow := DateTimeToUnix(...)-SkewSeconds;),避免冗余
  • 可将SkewSeconds设置为300(5分钟),为服务器时间同步预留合理缓冲
  • 确保服务端系统时间与UTC时间精准同步,避免因本地时间偏差引发的验证失败

内容的提问来源于stack exchange,提问作者Gianluca Colombo

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 09:40:54