cenobites / flask-jsonrpc

Basic JSON-RPC implementation for your Flask-powered sites
BSD 3-Clause "New" or "Revised" License
289 stars 63 forks source link

API browser does not work with flask-jwt #370

Open Talkless opened 1 year ago

Talkless commented 1 year ago

We use Flask-JWT for authentication. Every JSON-RPC call needs JWT token in Authorization header.

Are there any ideas how to make API Browser work with this kind of authentication?

Maybe it's possible to make it extendable, to be able to add some extra form fields where JWT username/password could be provided?

Also, to extention to perform something extra (/auth request) before main request seems needed. Also modifying request for adding Authorization header...

nycholas commented 1 year ago

Once the API Browser has the schema definition, we can overload the UI Template and extends that with some code that will help the API Browser put the JWT Token in the Authorization header. For example,

app.py

browse = JSONRPCBrowe(
   extensions = ['myst_parser'],
   parser = 'markdown',
   ....,
   template_index='./templates/index.html'
)

app = Flask('docstring')
jsonrpc_v1 = JSONRPC(app, '/api/v1', browse=browse)

./templates/index.html

{% extends "index.html" %}
{% block include_script %}
  <script src="//unpkg.com/something@x/some-jwt-bundle.js"></script>
  <script>
    let JWTToken = {};

    // Create an adapter to use the JWT Flask, seems like keycloak-adapter-js

    window.JSONRPCBrowse = JSONRPCBrowse({
        requestInterceptor: (request) => {
          request.headers['Authorization'] = JWTToken.accessToken;
          return request;
        }
      });
  </script>
{% endblock %}
{% endblock %}

It is the generic form, but the API Browse can support the specific provider by extensions, as simple as is:

app.py

browse = JSONRPCBrowe(
   extensions = ['myst_parser', 'flask-jwt'],
   parser = 'markdown',
   ....,
  {'flask_jwt': {
     public_key: '...',
     secret_key: '...',
     ....,
  }}
)

app = Flask('docstring')
jsonrpc_v1 = JSONRPC(app, '/api/v1', browse=browse)

What do you think about that?

Talkless commented 1 year ago

It's a bit hard to get grok of your suggestion, but I guess generally ability to extend templates might work, I guess.

This is some quick-and-dirty hack (without understanding how AngularJS magic works) to make it work, by adding authentication form fields and modifying controllers:

diff --git a/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js b/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js
index 5fcd9f3..4df5d7a 100644
--- a/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js
+++ b/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js
@@ -20,6 +20,10 @@
         $scope.response = responseExample;
         $scope.response_object = responseObjectExample;

+        window.username = "";
+        window.password = "";
+        window.customer = "";
+
         $scope.$on('App:displayFakeIntro', function(event, display) {
             $scope.showFakeIntro = display;
         });
@@ -108,31 +112,56 @@
         };
     }]);

-    App.controller('ResponseObjectCtrl', ['$scope', '$window', '$modal', 'RPC', 'module', function($scope, $window, $modal, RPC, module) {
+    App.controller('ResponseObjectCtrl', ['$scope', '$window', '$modal', 'RPC', 'module', 'serviceUrl', function($scope, $window, $modal, RPC, module, serviceUrl) {
         $scope.module = module;
+        $scope.module.username = $window.username;
+        $scope.module.password = $window.password;
+        $scope.module.customer = $window.customer;
         $scope.$emit('App:displayToolbar', true);
         $scope.$emit('App:breadcrumb', module.name);

         var RPCCall = function(module) {
-            var payload = RPC.payload(module);
-            $scope.request_object = payload;
-            $scope.response = undefined;
-            $scope.response_object = undefined;
-            RPC.callWithPayload(payload).success(function(response_object, status, headers, config) { // success
-                var headers_pretty = headers();
-                headers_pretty.data = config.data;
-
-                $scope.response = {status: status, headers: headers_pretty, config: config};
-                $scope.response_object = response_object;
-                $scope.$emit('App:displayContentLoaded', false);
-            }).error(function(response_object, status, headers, config) { // error
-                var headers_pretty = headers();
-                headers_pretty.data = config.data;

-                $scope.response = {status_code: status, headers: headers_pretty, config: config};
-                $scope.response_object = response_object;
-                $scope.$emit('App:displayContentLoaded', false);
+            window.username = module.username;
+            window.password = module.password;
+            window.customer = module.customer;
+
+            RPC.generateJWT(module.username, module.password, module.customer).success(function(response_object, status, headers, config) {
+
+                const jwt = response_object['access_token'];
+                console.log("JWT:", jwt);
+
+                var payload = RPC.payload(module);
+                $scope.request_object = payload;
+                $scope.response = undefined;
+                $scope.response_object = undefined;
+
+                RPC.callWithPayload(payload, {method: 'POST', url: serviceUrl, headers: { 'Authorization': "JWT " + jwt }} ).success(function(response_object, status, headers, config) { // success
+                    var headers_pretty = headers();
+                    headers_pretty.data = config.data;
+
+                    $scope.response = {status: status, headers: headers_pretty, config: config};
+                    $scope.response_object = response_object;
+                    $scope.$emit('App:displayContentLoaded', false);
+
+                    }).error(function(response_object, status, headers, config) { // error
+                        var headers_pretty = headers();
+                        headers_pretty.data = config.data;
+
+                        $scope.response = {status_code: status, headers: headers_pretty, config: config};
+                        $scope.response_object = response_object;
+                        $scope.$emit('App:displayContentLoaded', false);
+                });
+
+            }).error(function(response_object, status, headers, config) { // JWT error
+                    var headers_pretty = headers();
+                    headers_pretty.data = config.data;
+
+                    $scope.response = {status_code: status, headers: headers_pretty, config: config};
+                    $scope.response_object = response_object;
+                    $scope.$emit('App:displayContentLoaded', false);
             });
+
         },
         RPCCallModal = function(module) {
             $modal.open({
diff --git a/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js b/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js
index 9b0b287..7a45e59 100644
--- a/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js
+++ b/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js
@@ -116,6 +116,14 @@

                     return payload;
                 },
+                generateJWT: function(username, password, customer) {
+                    const options = {
+                        method: 'POST',
+                        url: '/auth',
+                        data: { "username" : customer + ";" + username, "password" : password },
+                    };
+                    return $http(options);
+                },
                 callWithPayload: function(data, options) {
                     var options = options || {method: 'POST', url: serviceUrl};
                     options.data = data;
diff --git a/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html b/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html
index 9b57a5e..b8075bc 100644
--- a/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html
+++ b/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html
@@ -9,7 +9,14 @@
       <div class="modal-body">
         <h5><b>Summary:</b> <span ng-if="!module.summary">None</span><span style="white-space: pre-wrap;">{{module.summary}}</span></h5>
         <ng-form name="nameDialog" novalidate role="form">
-          <div class="form-group input-group-lg">
+          <div class="form-group input-group-sm">
+            <label for="username">Username</label>
+            <input type="text" class="form-control" autocomplete="on" name="username" id="username" ng-model="module.username" ng-keyup="hitEnter($event)" required>
+            <label for="password">Password</label>
+            <input type="password" class="form-control" autocomplete="on" name="password" id="password" ng-model="module.password" ng-keyup="hitEnter($event)" required>
+            <label for="customer">Customer</label>
+            <input type="text" class="form-control" autocomplete="on" name="customer" id="customer" ng-model="module.customer" ng-keyup="hitEnter($event)" required>
+            <hr/>
             <span ng-repeat="param in module.params">
               <label class="control-label" for="course">{{param.name}} -> {{param.type}}: </label><input type="text" class="form-control" name="{{param.name}}" id="{{param.name}}" ng-model="param.value" ng-keyup="hitEnter($event)" required>
               <span class="help-block"></span>
nycholas commented 1 year ago

It seems to work. One question, Is every request the client needs to ask for a new JWT Token?

For your example, I think the good decision is the approach of API Browser extension to support that.

Talkless commented 1 year ago

It seems to work. One question, Is every request the client needs to ask for a new JWT Token?

No, it does need new token for each request. Simply in order not to check for JWT expiration error (and re-generate transparenlty), I've just made it get a new one on every request. This behavior is only in the Browser of course, not in production. Again, it's a quick-and-very-dirty hack :) .

Talkless commented 2 months ago

Updated patch to introduce JWT authentication for documentation requests in 3.0.1, as there were some minor changes:

Index: flask-jsonrpc/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js
===================================================================
--- flask-jsonrpc/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js (revision 244)
+++ flask-jsonrpc/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/controllers.js (working copy)
@@ -21,6 +21,10 @@
         $scope.response = responseExample;
         $scope.responseObject = responseObjectExample;

+        window.username = "";
+        window.password = "";
+        window.customer = "";
+
         $scope.$on('App:displayFakeIntro', function(event, display) {
             $scope.showFakeIntro = display;
         });
@@ -119,32 +123,55 @@
         };
     }]);

-    App.controller('ResponseObjectCtrl', ['$scope', '$window', '$modal', 'RPC', 'module', function($scope, $window, $modal, RPC, module) {
+    App.controller('ResponseObjectCtrl', ['$scope', '$window', '$modal', 'RPC', 'module', 'serverUrls', function($scope, $window, $modal, RPC, module, serverUrls) {
         $scope.module = module;
+        $scope.module.username = $window.username;
+        $scope.module.password = $window.password;
+        $scope.module.customer = $window.customer;
         $scope.$emit('App:displayToolbar', true);
         $scope.$emit('App:breadcrumb', module.name);
         $scope.$emit('App:displayToolbarNotifyButton', module.options.notification);

         var RPCCall = function(module) {
-            var payload = RPC.payload(module);
-            $scope.requestObject = payload;
-            $scope.response = undefined;
-            $scope.responseObject = undefined;
-            RPC.callWithPayload(payload).success(function(responseObject, status, headers, config) { // success
-                var headersPretty = headers();
-                headersPretty.data = config.data;
+            window.username = module.username;
+            window.password = module.password;
+            window.customer = module.customer;
+            $scope.responseObject = "Getting token...";
+            RPC.generateJWT(module.username, module.password, module.customer).success(function(responseObject, status, headers, config) {

-                $scope.response = {status: status, headers: headersPretty, config: config};
-                $scope.responseObject = responseObject;
-                $scope.$emit('App:displayContentLoaded', false);
-            }).error(function(responseObject, status, headers, config) { // error
-                var headersPretty = headers();
-                headersPretty.data = config.data;
+                const jwt = responseObject['access_token'];
+                //console.log("JWT:", jwt);

-                $scope.response = {statusCode: status, headers: headersPretty, config: config};
-                $scope.responseObject = responseObject;
-                $scope.$emit('App:displayContentLoaded', false);
-            });
+                var payload = RPC.payload(module);
+                $scope.requestObject = payload;
+                $scope.response = undefined;
+                $scope.responseObject = "Loading result...";
+
+                RPC.callWithPayload(payload, {method: 'POST', url: serverUrls[payload.method], headers: { 'Authorization': "Bearer " + jwt }} ).success(function(responseObject, status, headers, config) { // success
+                    var headersPretty = headers();
+                    headersPretty.data = config.data;
+
+                    $scope.response = {status: status, headers: headersPretty, config: config};
+                    $scope.responseObject = responseObject;
+                    $scope.$emit('App:displayContentLoaded', false);
+
+                    }).error(function(responseObject, status, headers, config) { // error
+                        var headersPretty = headers();
+                        headersPretty.data = config.data;
+
+                        $scope.response = {status_code: status, headers: headersPretty, config: config};
+                        $scope.responseObject = responseObject;
+                        $scope.$emit('App:displayContentLoaded', false);
+                });
+
+            }).error(function(responseObject, status, headers, config) { // JWT error
+                    var headersPretty = headers();
+                    headersPretty.data = config.data;
+
+                    $scope.response = {status_code: status, headers: headersPretty, config: config};
+                    $scope.responseObject = responseObject;
+                    $scope.$emit('App:displayContentLoaded', false);
+             });
         },
         RPCCallModal = function(module) {
             $modal.open({
Index: flask-jsonrpc/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js
===================================================================
--- flask-jsonrpc/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js    (revision 244)
+++ flask-jsonrpc/src/flask_jsonrpc/contrib/browse/static/js/apps/browse/services.js    (working copy)
@@ -120,6 +120,14 @@

                     return payload;
                 },
+                generateJWT: function(username, password, customer) {
+                    const options = {
+                        method: 'POST',
+                        url: '/auth',
+                        data: { "username" : customer + ";" + username, "password" : password },
+                    };
+                    return $http(options);
+                },
                 callWithPayload: function(data, options) {
                     var serviceUrl = serverUrls[data.method];
                     var options = options || {method: 'POST', url: serviceUrl};
Index: flask-jsonrpc/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html
===================================================================
--- flask-jsonrpc/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html   (revision 244)
+++ flask-jsonrpc/src/flask_jsonrpc/contrib/browse/templates/browse/partials/response_object.html   (working copy)
@@ -9,7 +9,14 @@
       <div class="modal-body">
         <h5><b>Description:</b> <span ng-if="!module.description">None</span><span style="white-space: pre-wrap;">{{module.description}}</span></h5>
         <ng-form name="nameDialog" novalidate role="form">
-          <div class="form-group input-group-lg">
+          <div class="form-group input-group-sm">
+            <label for="username">Username</label>
+            <input type="text" class="form-control" autocomplete="on" name="username" id="username" ng-model="module.username" ng-keyup="hitEnter($event)" required>
+            <label for="password">Password</label>
+            <input type="password" class="form-control" autocomplete="on" name="password" id="password" ng-model="module.password" ng-keyup="hitEnter($event)" required>
+            <label for="customer">Customer</label>
+            <input type="text" class="form-control" autocomplete="on" name="customer" id="customer" ng-model="module.customer" ng-keyup="hitEnter($event)" required>
+            <hr/>
             <span ng-repeat="param in module.params">
               <label class="control-label" for="course">{{param.name}} -> {{param.type}}: </label><input type="text" class="form-control" name="{{param.name}}" id="{{param.name}}" ng-model="param.value" ng-keyup="hitEnter($event)" required>
               <span class="help-block"></span>