unit Sparkle.Middleware.Jwt;

{$I sparkle.inc}

interface

{$IFDEF DELPHIXE6_LVL}

uses
  System.SysUtils,
  Bcl.JOSE.Core.JWT,
  Bcl.JOSE.Core.JWK,
  Bcl.JOSE.Core.Builder,
  Bcl.JOSE.Core.Base,
  Bcl.Json.Classes,
  Sparkle.HttpServer.Module,
  Sparkle.HttpServer.Context,
  Sparkle.Security;

type
  IUserIdentity = Sparkle.Security.IUserIdentity;
  TUserIdentity = Sparkle.Security.TUserIdentity;
  TUserClaim = Sparkle.Security.TUserClaim;

  IJwtMiddleware = interface(IHttpServerMiddleware)
  ['{84754A6B-5BE8-4C71-A40E-153F8EDD7BC1}']
  end;

  TJwtMiddleware = class(THttpServerMiddleware, IJwtMiddleware)
  private
    FSecret: TBytes;
    FForbidAnonymousAccess: Boolean;
    FAllowExpiredToken: Boolean;
    function IsTokenExpired(User: IUserIdentity): Boolean;
  protected
    procedure UnauthorizedAnswer(Context: THttpServerContext; const Msg: string); virtual;
    function RetrieveIdentity(AJWT: TJWT): IUserIdentity; virtual;
    procedure DoAuthenticate(AJWT: TJWT; var User: IUserIdentity); virtual;
    procedure ProcessRequest(Context: THttpServerContext; Next: THttpServerProc); override;
  public
    constructor Create(const ASecret: string; AForbidAnonymousAccess: Boolean = False;
      AAllowExpiredToken: Boolean = False); overload;
    constructor Create(const ASecret: TBytes; AForbidAnonymousAccess: Boolean = False;
      AAllowExpiredToken: Boolean = False); overload;
    property Secret: TBytes read FSecret write FSecret;
    property AllowExpiredToken: Boolean read FAllowExpiredToken write FAllowExpiredToken;
    property ForbidAnonymousAccess: Boolean read FForbidAnonymousAccess write FForbidAnonymousAccess;
  end;

{$ENDIF}

implementation

{$IFDEF DELPHIXE6_LVL}

uses
  System.JSON, System.DateUtils,
  Sparkle.Utils;

{ TJwtMiddleware }

function TJwtMiddleware.IsTokenExpired(User: IUserIdentity): Boolean;
begin
  Result := not AllowExpiredToken and (User <> nil) and User.Claims.Exists('exp') and
    (User.Claims['exp'].AsEpoch < Now);
end;

constructor TJwtMiddleware.Create(const ASecret: string;
  AForbidAnonymousAccess: Boolean = False; AAllowExpiredToken: Boolean = False);
begin
  FSecret := TEncoding.UTF8.GetBytes(ASecret);
  FAllowExpiredToken := AAllowExpiredToken;
  FForbidAnonymousAccess := AForbidAnonymousAccess;
end;

constructor TJwtMiddleware.Create(const ASecret: TBytes;
  AForbidAnonymousAccess: Boolean = False; AAllowExpiredToken: Boolean = False);
begin
  FSecret := ASecret;
  FAllowExpiredToken := AAllowExpiredToken;
  FForbidAnonymousAccess := AForbidAnonymousAccess;
end;

procedure TJwtMiddleware.DoAuthenticate(AJWT: TJWT; var User: IUserIdentity);
var
  I: Integer;
  Pair: TJSONPair;
begin
  User := TUserIdentity.Create;
  for I := 0 to AJWT.Claims.JSON.Count - 1 do
  begin
    Pair := AJWT.Claims.JSON.Pairs[I];
    User.Claims.AddOrSet(Pair.JsonString.Value).SetJElement(
      TJElement.FromJSONValue(Pair.JsonValue));
  end;
end;

procedure TJwtMiddleware.ProcessRequest(Context: THttpServerContext; Next: THttpServerProc);
var
  Auth, Token: string;
  JWT: TJWT;
  Key: TJWK;
begin
  // Forward OPTIONS requests in case CORS is being handled by the module, not
  // my the middleware
  if Context.Request.Method = 'OPTIONS' then
  begin
    Next(Context);
    Exit;
  end;

  // Add www-authenticate header if response is 401
  Context.Response.OnHeaders(
    procedure(Response: THttpServerResponse)
    var
      Challenge: string;
    begin
      if Response.StatusCode <> 401 then
        Exit;
      if Response.Headers.Exists('www-authenticate') then
        Exit;

      Challenge := 'Bearer';
      Response.Headers.SetValue('www-authenticate', Challenge);
    end
  );

  // get JWT token and retrieve User identity
  if Context.Request.User = nil then
  begin
    Auth := Context.Request.Headers.Get('authorization');
    if (Auth <> '') and SameText('Bearer ', Copy(Auth, 1, 7)) then
    begin
      Token := Copy(Auth, 8, MaxInt);
      Key := TJWK.Create(Secret);
      try
        try
          JWT := TJOSE.Verify(Key, Token);
          try
            if (JWT = nil) or not JWT.Verified then
            begin
              UnauthorizedAnswer(Context, 'Invalid JWT');
              Exit;
            end;

            Context.Request.User := RetrieveIdentity(JWT);
          finally
            JWT.Free;
          end;
        except
          on E: EJOSEException do
          begin
            UnauthorizedAnswer(Context, E.Message);
            Exit;
          end
          else
            raise;
        end;
      finally
        Key.Free;
      end;
    end;
  end;

  // Do security checks
  if IsTokenExpired(Context.Request.User) then
  begin
    UnauthorizedAnswer(Context, 'Token expired');
    Exit;
  end;

  if ForbidAnonymousAccess and (Context.Request.User = nil) then
  begin
    UnauthorizedAnswer(Context, 'Unauthorized');
    Exit;
  end;

  Next(Context);
end;

function TJwtMiddleware.RetrieveIdentity(AJWT: TJWT): IUserIdentity;
begin
  Result := nil;
  DoAuthenticate(AJWT, Result);
end;

procedure TJwtMiddleware.UnauthorizedAnswer(Context: THttpServerContext; const Msg: string);
begin
  Context.Response.StatusCode := 401;
  if Msg <> '' then
  begin
    Context.Response.ContentType := 'text/plain';
    Context.Response.Close(TEncoding.UTF8.GetBytes(Msg));
  end;
end;

{$ENDIF}

end.
