Delphi12实现FCM推送服务遇JWT时间校验错误排查
我用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:
1744725745OAuth: {"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

