unit Auth.Service;

interface

uses
  SysUtils, Web, JS,
  XData.Web.Client;

const
  TOKEN_NAME      = 'WEBEMIMOBILE_TOKEN';
  CREDENTIAL_NAME = 'WEBEMIMOBILE_CREDENTIAL_ID';

type
  TOnLoginSuccess = reference to procedure;
  TOnLoginError   = reference to procedure(AMsg: string);

  TOnBeginSuccess  = reference to procedure(AChallenge, AChallengeToken: string);
  TOnDeviceSuccess = reference to procedure;
  TOnDeviceError   = reference to procedure(AMsg: string);

  TAuthService = class
  private
    FClient: TXDataWebClient;
    procedure SetToken(AToken: string);
    procedure DeleteToken;
    procedure SetCredentialId(AId: string);
  public
    constructor Create; reintroduce;
    destructor Destroy; override;

    // JWT helpers
    procedure Logout;
    function GetToken: string;
    function Authenticated: Boolean;
    function TokenExpirationDate: TDateTime;
    function TokenExpired: Boolean;
    function TokenPayload: JS.TJSObject;

    // Credential (WebAuthn) storage
    function GetCredentialId: string;
    function IsDeviceRegistered: Boolean;
    procedure ClearCredentialId;

    // WebAuthn registration — two-step
    procedure BeginRegistration(ADeviceName: string;
      ASuccess: TOnBeginSuccess; AError: TOnDeviceError);
    procedure CompleteRegistration(ADeviceName, ACredentialId,
      AAttestationObject, AClientDataJSON, AChallengeToken: string;
      ASuccess: TOnDeviceSuccess; AError: TOnDeviceError);

    // WebAuthn authentication — two-step (called from within Login flow)
    procedure BeginAuthentication(ACredentialId: string;
      ASuccess: TOnBeginSuccess; AError: TOnLoginError);
    procedure LoginWithAssertion(AUser, APassword, AAgency, ACredentialId,
      AChallengeToken, AAuthenticatorData, AClientDataJSON, ASignature: string;
      ASuccess: TOnLoginSuccess; AError: TOnLoginError);
  end;

  TJwtHelper = class
  private
    class function HasExpirationDate(AToken: string): Boolean;
  public
    class function TokenExpirationDate(AToken: string): TJSDate;
    class function TokenExpired(AToken: string): Boolean;
    class function DecodePayload(AToken: string): string;
  end;

  function AuthService: TAuthService;

implementation

uses
  ConnectionModule;

var
  _AuthService: TAuthService;

function AuthService: TAuthService;
begin
  if not Assigned(_AuthService) then
    _AuthService := TAuthService.Create;
  Result := _AuthService;
end;

{ TAuthService }

constructor TAuthService.Create;
begin
  FClient := TXDataWebClient.Create(nil);
  FClient.Connection := DMConnection.AuthConnection;
end;

destructor TAuthService.Destroy;
begin
  FClient.Free;
  inherited;
end;

// ---- JWT storage ----

procedure TAuthService.SetToken(AToken: string);
begin
  window.localStorage.setItem(TOKEN_NAME, AToken);
end;

procedure TAuthService.DeleteToken;
begin
  window.localStorage.removeItem(TOKEN_NAME);
end;

function TAuthService.GetToken: string;
begin
  Result := window.localStorage.getItem(TOKEN_NAME);
end;

function TAuthService.Authenticated: Boolean;
begin
  Result := not isNull(window.localStorage.getItem(TOKEN_NAME)) and
            (window.localStorage.getItem(TOKEN_NAME) <> '');
end;

procedure TAuthService.Logout;
begin
  DeleteToken;
end;

// ---- Credential ID storage ----

procedure TAuthService.SetCredentialId(AId: string);
begin
  window.localStorage.setItem(CREDENTIAL_NAME, AId);
end;

procedure TAuthService.ClearCredentialId;
begin
  window.localStorage.removeItem(CREDENTIAL_NAME);
end;

function TAuthService.GetCredentialId: string;
begin
  Result := window.localStorage.getItem(CREDENTIAL_NAME);
end;

function TAuthService.IsDeviceRegistered: Boolean;
begin
  Result := not isNull(window.localStorage.getItem(CREDENTIAL_NAME)) and
            (window.localStorage.getItem(CREDENTIAL_NAME) <> '');
end;

// ---- WebAuthn registration ----

procedure TAuthService.BeginRegistration(ADeviceName: string;
  ASuccess: TOnBeginSuccess; AError: TOnDeviceError);

  procedure OnLoad(Response: TXDataClientResponse);
  var
    resp: JS.TJSObject;
    challenge, token, errMsg: string;
  begin
    resp    := JS.TJSObject(Response.Result);
    errMsg  := JS.toString(resp.Properties['error']);
    if errMsg <> '' then
    begin
      AError(errMsg);
      Exit;
    end;
    challenge := JS.toString(resp.Properties['challenge']);
    token     := JS.toString(resp.Properties['challengeToken']);
    ASuccess(challenge, token);
  end;

  procedure OnError(Error: TXDataClientError);
  begin
    AError(Format('%s: %s', [Error.ErrorCode, Error.ErrorMessage]));
  end;

begin
  FClient.RawInvoke(
    'IAuthService.BeginRegistration',
    [ADeviceName],
    @OnLoad, @OnError
  );
end;

procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
  AAttestationObject, AClientDataJSON, AChallengeToken: string;
  ASuccess: TOnDeviceSuccess; AError: TOnDeviceError);

  procedure OnLoad(Response: TXDataClientResponse);
  var
    resp: JS.TJSObject;
    status, msg, credId: string;
  begin
    resp   := JS.TJSObject(Response.Result);
    status := JS.toString(resp.Properties['status']);
    msg    := JS.toString(resp.Properties['message']);
    credId := JS.toString(resp.Properties['credentialId']);

    if status = 'revoked' then
      AError('Access denied: ' + msg)
    else if status = 'ok' then
    begin
      SetCredentialId(credId);
      ASuccess;
    end
    else
      AError(msg);
  end;

  procedure OnError(Error: TXDataClientError);
  begin
    AError(Format('%s: %s', [Error.ErrorCode, Error.ErrorMessage]));
  end;

begin
  FClient.RawInvoke(
    'IAuthService.CompleteRegistration',
    [ADeviceName, ACredentialId, AAttestationObject, AClientDataJSON, AChallengeToken],
    @OnLoad, @OnError
  );
end;

// ---- WebAuthn authentication ----

procedure TAuthService.BeginAuthentication(ACredentialId: string;
  ASuccess: TOnBeginSuccess; AError: TOnLoginError);

  procedure OnLoad(Response: TXDataClientResponse);
  var
    resp: JS.TJSObject;
    challenge, token, errMsg: string;
  begin
    resp   := JS.TJSObject(Response.Result);
    errMsg := JS.toString(resp.Properties['error']);
    if errMsg <> '' then
    begin
      AError(errMsg);
      Exit;
    end;
    challenge := JS.toString(resp.Properties['challenge']);
    token     := JS.toString(resp.Properties['challengeToken']);
    ASuccess(challenge, token);
  end;

  procedure OnError(Error: TXDataClientError);
  begin
    AError(Format('%s: %s', [Error.ErrorCode, Error.ErrorMessage]));
  end;

begin
  FClient.RawInvoke(
    'IAuthService.BeginAuthentication',
    [ACredentialId],
    @OnLoad, @OnError
  );
end;

procedure TAuthService.LoginWithAssertion(AUser, APassword, AAgency, ACredentialId,
  AChallengeToken, AAuthenticatorData, AClientDataJSON, ASignature: string;
  ASuccess: TOnLoginSuccess; AError: TOnLoginError);

  procedure OnLoad(Response: TXDataClientResponse);
  var
    Token: JS.TJSObject;
  begin
    Token := JS.TJSObject(Response.Result);
    SetToken(JS.toString(Token.Properties['value']));
    ASuccess;
  end;

  procedure OnError(Error: TXDataClientError);
  begin
    AError(Format('%s: %s', [Error.ErrorCode, Error.ErrorMessage]));
  end;

begin
  FClient.RawInvoke(
    'IAuthService.Login',
    [AUser, APassword, AAgency, ACredentialId,
     AChallengeToken, AAuthenticatorData, AClientDataJSON, ASignature],
    @OnLoad, @OnError
  );
end;

// ---- Token helpers ----

function TAuthService.TokenExpirationDate: TDateTime;
var
  ExpirationDate: TJSDate;
begin
  if not Authenticated then
    Exit(Now);

  ExpirationDate := TJwtHelper.TokenExpirationDate(GetToken);

  Result := EncodeDate(
              ExpirationDate.FullYear,
              ExpirationDate.Month + 1,
              ExpirationDate.Date
            ) +
            EncodeTime(
              ExpirationDate.Hours,
              ExpirationDate.Minutes,
              ExpirationDate.Seconds,
              0
            );
end;

function TAuthService.TokenExpired: Boolean;
begin
  if not Authenticated then
    Exit(False);
  Result := TJwtHelper.TokenExpired(GetToken);
end;

function TAuthService.TokenPayload: JS.TJSObject;
begin
  if not Authenticated then
    Exit(nil);
  Result := TJSObject(TJSJSON.parse(TJwtHelper.DecodePayload(GetToken)));
end;

{ TJwtHelper }

class function TJwtHelper.DecodePayload(AToken: string): string;
begin
  if Trim(AToken) = '' then
    Exit('');
  Result := '';
  asm
    var Token = AToken.split('.');
    if (Token.length === 3) {
      Result = Token[1];
      Result = atob(Result);
    }
  end;
end;

class function TJwtHelper.HasExpirationDate(AToken: string): Boolean;
var
  Payload: string;
  Obj: TJSObject;
begin
  Payload := DecodePayload(AToken);
  Obj := TJSObject(TJSJSON.parse(Payload));
  Result := Obj.hasOwnProperty('exp');
end;

class function TJwtHelper.TokenExpirationDate(AToken: string): TJSDate;
var
  Payload: string;
  Obj: TJSObject;
  Epoch: NativeInt;
begin
  if not HasExpirationDate(AToken) then
    raise Exception.Create('Token has no expiration date');

  Payload := DecodePayload(AToken);
  Obj := TJSObject(TJSJSON.parse(Payload));
  Epoch := toInteger(Obj.Properties['exp']);
  Result := TJSDate.New(Epoch * 1000);
end;

class function TJwtHelper.TokenExpired(AToken: string): Boolean;
begin
  if not HasExpirationDate(AToken) then
    Exit(False);
  Result := TJSDate.now > toInteger(TokenExpirationDate(AToken).valueOf);
end;

end.
