Initial token management

Change-Id: I6177b46961b7a0e53b9fa1fa9430a4d5562ae2da
diff --git a/Changes b/Changes
index 45365b1..7267d4d 100755
--- a/Changes
+++ b/Changes
@@ -2,6 +2,7 @@
         - Support X-Forwarded-Host name for proxy.
         - Document API URI.
         - Improve redirect handling in proxy.
+        - Added support for OAuth2 client registration.
 
 0.37 2020-01-16
         - Removed deprecated 'kalamar_test_port' helper.
diff --git a/dev/scss/base.scss b/dev/scss/base.scss
index f6dc152..2d71b4d 100644
--- a/dev/scss/base.scss
+++ b/dev/scss/base.scss
@@ -3,6 +3,7 @@
 @import "base/flextable";
 @import "base/fragment";
 @import "base/load";
+@import "base/form";
 
 /**
  * Basic global CSS rules for Kalamar
@@ -84,6 +85,10 @@
   }
 }
 
+button {
+  cursor: pointer;
+}
+
 button[type=submit] {
   font-weight: normal;
   @include choose-item;
diff --git a/dev/scss/base/form.scss b/dev/scss/base/form.scss
new file mode 100644
index 0000000..b2ae781
--- /dev/null
+++ b/dev/scss/base/form.scss
@@ -0,0 +1,87 @@
+// This class defines form views
+
+.form-table {
+  display: block;
+  padding: 0;
+
+  fieldset {
+    border-width: 0;
+    padding: 0;
+    margin: 0;
+    // margin-left: 5em;
+  }
+
+  fieldset > div {
+    white-space: nowrap;
+  }
+
+  legend {
+    background-color: transparent;
+    margin-left: 0;
+    font-weight: bold;
+    border-radius: $standard-border-radius;
+  }
+
+  label, input[type=radio] {
+    font-size: 80%;
+  }
+  
+  label[for] {
+    display: block;
+    text-align: left;
+  }
+  
+  label[for], input[type=submit] {
+    margin-top: 2em;
+  }
+
+  input, textarea, button {
+    border-radius: $standard-border-radius;
+  }
+  
+  input, textarea {
+    border-color: $ids-grey-2;
+    background-color: $nearly-white;
+    border-style: solid;
+  }
+  
+  input, textarea, select {
+    display: inline-block;
+    width: 20%;
+    min-width: 20em;
+    // margin: 0 20pt 0 20pt;
+    padding: $base-padding;
+  }
+
+  input[type=radio] {
+    display: inline;
+    text-aline: right;
+    background-color: red;
+    width: auto;
+    min-width: auto;
+  }
+  
+  input[readonly=readonly],textarea[readonly] {
+    background-color: $light-orange; // rgba(0,0,0,0.5);
+  }
+  
+  .field-with-error {
+    border-color: $ids-pink-1;
+  }
+
+  input:not([type=radio]), button {
+     height: 3em;
+  }
+
+  input[type=submit], button {
+     display: inline-block;
+     text-align: center;
+     background-color: $middle-green;
+     border-color: $dark-green;
+  }
+
+  label.field-required::after {
+    color: $ids-blue-1;
+    content:'*';
+  }
+}
\ No newline at end of file
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index 2ef30df..2f1632b 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -272,9 +272,18 @@
   $r->get('/doc')->to('documentation#page', page => 'korap')->name('doc_start');
   $r->get('/doc/:scope/:page')->to('documentation#page', scope => undef)->name('doc');
 
-  # Settings routes (deactivated)
-  # $r->get('/settings')->to(cb => sub { shift->render('settings') })->name('settings_start');
-  # $r->get('/settings/:scope/:page')->to(scope => undef, page => undef)->name('settings');
+  # Settings routes
+  if ($self->navi->exists('settings')) {
+    $r->get('/settings')->to(
+      cb => sub {
+        return shift->render('settings')
+      }
+    )->name('settings_start');
+    $r->get('/settings/:scope/:page')->to(
+      scope => undef,
+      page => undef
+    )->name('settings');
+  };
 
   # Contact route
   $r->get('/contact')->to('documentation#contact');
@@ -318,7 +327,7 @@
 
 =head2 COPYRIGHT AND LICENSE
 
-Copyright (C) 2015-2019, L<IDS Mannheim|http://www.ids-mannheim.de/>
+Copyright (C) 2015-2020, L<IDS Mannheim|http://www.ids-mannheim.de/>
 Author: L<Nils Diewald|http://nils-diewald.de/>
 
 Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
diff --git a/lib/Kalamar/Plugin/Auth.pm b/lib/Kalamar/Plugin/Auth.pm
index 7072f9f..920eb46 100644
--- a/lib/Kalamar/Plugin/Auth.pm
+++ b/lib/Kalamar/Plugin/Auth.pm
@@ -1,5 +1,7 @@
 package Kalamar::Plugin::Auth;
 use Mojo::Base 'Mojolicious::Plugin';
+use File::Basename 'dirname';
+use File::Spec::Functions qw/catdir/;
 use Mojo::ByteStream 'b';
 
 # This is a plugin to deal with the Kustvakt OAuth server.
@@ -47,6 +49,7 @@
     $app->log->error('client_id or client_secret not defined');
   };
 
+  # Load localize
   $app->plugin('Localize' => {
     dict => {
       Auth => {
@@ -61,7 +64,20 @@
           tokenExpired => 'Zugriffstoken abgelaufen',
           tokenInvalid => 'Zugriffstoken ungültig',
           refreshFail => 'Fehlerhafter Refresh-Token',
-          responseError => 'Unbekannter Autorisierungsfehler'
+          responseError => 'Unbekannter Autorisierungsfehler',
+          paramError => 'Einige Eingaben sind fehlerhaft',
+          redirectUri => 'Weiterleitungs-Adresse',
+          homepage => 'Webseite',
+          desc => 'Kurzbeschreibung',
+          clientCredentials => 'Client Daten',
+          clientType => 'Art der Client-Applikation',
+          clientName => 'Name der Client-Applikation',
+          clientID => 'ID der Client-Applikation',
+          clientSecret => 'Client-Secret',
+          clientRegister => 'Neue Client-Applikation registrieren',
+          registerSuccess => 'Registrierung erfolgreich',
+          registerFail => 'Registrierung fehlgeschlagen',
+          oauthSettings => 'OAuth',
         },
         -en => {
           loginSuccess => 'Login successful',
@@ -73,7 +89,20 @@
           tokenExpired => 'Access token expired',
           tokenInvalid => 'Access token invalid',
           refreshFail => 'Bad refresh token',
-          responseError => 'Unknown authorization error'
+          responseError => 'Unknown authorization error',
+          paramError => 'Some fields are invalid',
+          redirectUri => 'Redirect URI',
+          homepage => 'Homepage',
+          desc => 'Short description',
+          clientCredentials => 'Client Credentials',
+          clientType => 'Type of the client application',
+          clientName => 'Name of the client application',
+          clientID => 'ID of the client application',
+          clientSecret => 'Client secret',
+          clientRegister => 'Register new client application',
+          registerSuccess => 'Registration successful',
+          registerFail => 'Registration denied',
+          oauthSettings => 'OAuth',
         }
       }
     }
@@ -95,6 +124,11 @@
     }
   );
 
+  # The plugin path
+  my $path = catdir(dirname(__FILE__), 'Auth');
+
+  # Append "templates"
+  push @{$app->renderer->paths}, catdir($path, 'templates');
 
   # Get or set the user token necessary for authorization
   $app->helper(
@@ -577,6 +611,110 @@
         )->wait;
       }
     )->name('logout');
+
+    # If "experimental_registration" is set, open
+    # OAuth registration dialogues.
+    if ($param->{experimental_client_registration}) {
+
+      # Add settings
+      $app->navi->add(settings => (
+        $app->loc('Auth_oauthSettings'), 'oauth'
+      ));
+
+      # Route to oauth settings
+      $r->get('/settings/oauth')->to(
+        cb => sub {
+          return shift->render(template => 'auth/tokens')
+        }
+      );
+
+      # Route to oauth client registration
+      $r->post('/settings/oauth/register')->to(
+        cb => sub {
+          my $c = shift;
+          my $v = $c->validation;
+
+          unless ($c->auth->token) {
+
+            # TODO: not allowed
+            return $c->reply->not_found;
+          };
+
+          $v->csrf_protect;
+          $v->required('name', 'trim')->size(3, 255);
+          $v->required('type')->in('PUBLIC', 'CONFIDENTIAL');
+          $v->required('desc', 'trim')->size(3, 255);
+          $v->optional('url', 'trim')->like(qr/^(http|$)/i);
+          $v->optional('redirectUri', 'trim')->like(qr/^(http|$)/i);
+
+          # Render with error
+          if ($v->has_error) {
+            if ($v->has_error('csrf_token')) {
+              $c->notify(error => $c->loc('Auth_csrfFail'));
+            }
+            else {
+              $c->notify(warn => $c->loc('Auth_paramError'));
+            };
+            return $c->render(template => 'auth/tokens')
+          };
+
+          # Wait for async result
+          $c->render_later;
+
+          # Register on server
+          state $url = Mojo::URL->new($c->korap->api)->path('oauth2/client/register');
+          $c->korap_request('POST', $url => {} => json => {
+            name        => $v->param('name'),
+            type        => $v->param('type'),
+            description => $v->param('desc'),
+            url         => $v->param('url'),
+            redirectURI => $v->param('redirectURI')
+          })->then(
+            sub {
+              my $tx = shift;
+              my $result = $tx->result;
+
+              if ($result->is_error) {
+                return Mojo::Promise->reject;
+              };
+
+              my $json = $result->json;
+
+              # TODO:
+              #   Respond in template
+              my $client_id = $json->{client_id};
+              my $client_secret = $json->{client_secret};
+
+              $c->stash('client_name' => $v->param('name'));
+              $c->stash('client_desc' => $v->param('desc'));
+              $c->stash('client_type' => $v->param('type'));
+              $c->stash('client_url'  => $v->param('url'));
+              $c->stash('client_redirect_uri' => $v->param('redirectURI'));
+              $c->stash('client_id' => $client_id);
+
+              if ($client_secret) {
+                $c->stash('client_secret' => $client_secret);
+              };
+
+              $c->notify(success => $c->loc('Auth_en_registerSuccess'));
+
+              return $c->render(template => 'auth/register-success');
+            }
+          )->catch(
+            sub {
+              # Server may be irresponsible
+              my $err = shift;
+              $c->notify('error' => $c->loc('Auth_en_registerFail'));
+              return Mojo::Promise->reject($err);
+            }
+          )->finally(
+            sub {
+              return $c->redirect_to('settings' => { scope => 'oauth' });
+            }
+          );
+        }
+      )->name('oauth-register');
+    };
   }
 
   # Use JWT login
@@ -783,6 +921,8 @@
       }
     )->name('logout');
   };
+
+  $app->log->info('Successfully registered Auth plugin');
 };
 
 1;
@@ -821,3 +961,68 @@
 
 
 __END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+Kalamar::Plugin::Auth - OAuth-2.0-based authorization plugin
+
+=head1 DESCRIPTION
+
+L<Kalamar::Plugin::Auth> is an OAuth-2.0-based authorization
+plugin for L<Kalamar>. It requires a C<Kustvakt> full server
+with OAuth 2.0 capabilities.
+It is activated by loading C<Auth> as a plugin in the C<Kalamar.plugins>
+parameter in the Kalamar configuration.
+
+=head1 CONFIGURATION
+
+L<Kalamar::Plugin::Auth> supports the following parameter for the
+C<Kalamar-Auth> configuration section in the Kalamar configuration:
+
+=over 2
+
+=item B<client_id>
+
+The client identifier of Kalamar to be send with every OAuth 2.0
+management request.
+
+=item B<client_secret>
+
+The client secret of Kalamar to be send with every OAuth 2.0
+management request.
+
+=item B<oauth2>
+
+Initially L<Kalamar-Plugin-Auth> was based on JWT. This parameter
+is historically used to switch between oauth2 and jwt. It is expected
+to be deprecated in the future, but for the moment it is required
+to be set to a true value.
+
+=item B<experimental_client_registration>
+
+Activates the oauth client registration flow.
+
+=back
+
+=head2 COPYRIGHT AND LICENSE
+
+Copyright (C) 2015-2020, L<IDS Mannheim|http://www.ids-mannheim.de/>
+Author: L<Nils Diewald|http://nils-diewald.de/>
+
+Kalamar is developed as part of the L<KorAP|http://korap.ids-mannheim.de/>
+Corpus Analysis Platform at the
+L<Leibniz Institute for the German Language (IDS)|http://ids-mannheim.de/>,
+member of the
+L<Leibniz-Gemeinschaft|http://www.leibniz-gemeinschaft.de>
+and supported by the L<KobRA|http://www.kobra.tu-dortmund.de> project,
+funded by the
+L<Federal Ministry of Education and Research (BMBF)|http://www.bmbf.de/en/>.
+
+Kalamar is free software published under the
+L<BSD-2 License|https://raw.githubusercontent.com/KorAP/Kalamar/master/LICENSE>.
+
+=cut
diff --git a/lib/Kalamar/Plugin/Auth/templates/auth/register-success.html.ep b/lib/Kalamar/Plugin/Auth/templates/auth/register-success.html.ep
new file mode 100644
index 0000000..2218236
--- /dev/null
+++ b/lib/Kalamar/Plugin/Auth/templates/auth/register-success.html.ep
@@ -0,0 +1,24 @@
+% extends 'settings', title => 'KorAP: '.loc('Auth_oauthSettings'), page => 'oauth';
+
+%= page_title
+
+<form class="form-table">
+  <fieldset>
+    <legend><%= loc 'Auth_clientCredentials' %></legend>
+    <p><strong><%= stash 'client_name' %></strong></p>
+    % if (stash('client_desc')) {
+    <p><%= stash 'client_desc' %></p>
+    % };
+    <p><%= loc 'Auth_clientType' %>: <%= stash 'client_type' %></p>
+    <div>
+      %= label_for 'client_id' => loc('Auth_clientID')
+      %= text_field 'client_id', stash('client_id'), readonly => 'readonly'
+    </div>
+    % if (stash('client_type') ne 'PUBLIC') {
+    <div>
+      %= label_for 'client_secret' => loc('Auth_clientSecret')
+      %= password_field 'client_secret', value => stash('client_secret'), readonly => 'readonly'
+    </div>
+    % };
+  </fieldset>
+</form>
diff --git a/lib/Kalamar/Plugin/Auth/templates/auth/tokens.html.ep b/lib/Kalamar/Plugin/Auth/templates/auth/tokens.html.ep
new file mode 100644
index 0000000..d5fe095
--- /dev/null
+++ b/lib/Kalamar/Plugin/Auth/templates/auth/tokens.html.ep
@@ -0,0 +1,41 @@
+% extends 'settings', title => 'KorAP: '.loc('Auth_oauthSettings'), page => 'oauth';
+
+%= page_title
+
+%= form_for 'oauth-register', class => 'form-table oauth-register', begin
+  <fieldset>
+    %= csrf_field
+    <legend><%= loc('Auth_clientRegister') %></legend>
+
+    <div>
+      %= label_for name => loc('Auth_clientName'), class => 'field-required', maxlength => 255
+      %= text_field 'name'
+    </div>
+
+    <div>
+      %= label_for type => loc('Auth_clientType'), class => 'field-required'
+      <%= radio_button type => 'PUBLIC', checked => 'checked' %>
+      <label>Public</label>
+      <br />
+      <%= radio_button type => 'CONFIDENTIAL' %>
+      <label>Confidential</label>
+    </div>
+
+    <div>
+      %= label_for 'desc' => loc('Auth_desc'), class => 'field-required'
+      %= text_field 'desc'
+    </div>
+
+    <div>
+      %= label_for name => loc('Auth_homepage')
+      %= url_field 'url', placeholder => 'https://...'
+    </div>
+
+    <div>
+      %= label_for name => loc('Auth_redirectUri')
+      %= url_field 'redirectURI', placeholder => 'https://...'
+    </div>
+
+    %= submit_button loc('Auth_clientRegister')
+  </fieldset>
+% end
diff --git a/lib/Kalamar/Plugin/KalamarPages.pm b/lib/Kalamar/Plugin/KalamarPages.pm
index c7694e4..eba3dbd 100644
--- a/lib/Kalamar/Plugin/KalamarPages.pm
+++ b/lib/Kalamar/Plugin/KalamarPages.pm
@@ -152,6 +152,14 @@
       # Take items from central list
       unless ($items) {
         $items = $navi->{$realm};
+
+        # Realm has no entries
+        return '' unless $items;
+      }
+
+      # Set realm
+      else {
+        $navi->{$realm} = $items;
       };
 
       # Create unordered list
@@ -270,6 +278,7 @@
     }
   );
 
+  # Add an item to the realm
   $mojo->helper(
     'navi.add' => sub {
       my $c = shift;
@@ -284,6 +293,19 @@
       }
     }
   );
+
+  # Check for existence
+  $mojo->helper(
+    'navi.exists' => sub {
+      my $c = shift;
+      my $realm = shift;
+      unless (exists $navi->{$realm}) {
+        return 0 ;
+      };
+      return 0 unless ref $navi->{$realm} && @{$navi->{$realm}} > 0;
+      return 1;
+    }
+  );
 }
 
 1;
diff --git a/t/intro.t b/t/intro.t
index ed8181a..0069713 100644
--- a/t/intro.t
+++ b/t/intro.t
@@ -16,6 +16,10 @@
   ->text_is('div.intro > p > strong', 'KorAP')
   ;
 
+# Only routed when existing
+$t->get_ok('/settings')
+  ->status_is(404);
+
 push @{$t->app->renderer->paths}, path(path(__FILE__)->dirname);
 
 $t->app->plugin(Localize => {
diff --git a/t/navigation.t b/t/navigation.t
index f9518e8..b1a9f8e 100644
--- a/t/navigation.t
+++ b/t/navigation.t
@@ -141,6 +141,10 @@
      'Path matches doc/ql/poliqarp-plus#complex');
 like($render, qr!/doc/faq!, 'Path matches doc/faq');
 
+ok($app->navi->exists('doc'));
+ok(!$app->navi->exists('xy'));
+is($app->navigation('xy'), '');;
+
 
 my $c = $app->build_controller;
 $c->stash(page => 'korap');
diff --git a/t/plugin/auth-oauth.t b/t/plugin/auth-oauth.t
index f03f61e..c9c4207 100644
--- a/t/plugin/auth-oauth.t
+++ b/t/plugin/auth-oauth.t
@@ -18,7 +18,8 @@
   'Kalamar-Auth' => {
     client_id => 2,
     client_secret => 'k414m4r-s3cr3t',
-    oauth2 => 1
+    oauth2 => 1,
+    experimental_client_registration => 1
   }
 });
 
@@ -371,7 +372,7 @@
 
 
 # The token is invalid and can't be refreshed!
-$t->get_ok('/?q=baum&cutoff=true')
+$csrf = $t->get_ok('/?q=baum&cutoff=true')
   ->status_is(200)
   ->session_hasnt('/auth')
   ->session_hasnt('/auth_r')
@@ -380,8 +381,57 @@
   ->text_is('title', 'KorAP: Find »baum« with Poliqarp')
   ->content_unlike(qr/\"authorized\"\:\"yes\"/)
   ->element_exists('p.no-results')
+  ->tx->res->dom->at('input[name="csrf_token"]')
+  ->attr('value')
   ;
 
+# Login:
+$t->post_ok('/user/login' => form => {
+  handle_or_email => 'test',
+  pwd => 'pass',
+  csrf_token => $csrf
+})
+  ->status_is(302)
+  ->content_is('')
+  ->header_is('Location' => '/');
+
+$t->get_ok('/')
+  ->status_is(200)
+  ->element_exists_not('div.notify-error')
+  ->element_exists('div.notify-success')
+  ->text_is('div.notify-success', 'Login successful')
+  ->element_exists('aside.off')
+  ->element_exists_not('aside.active')
+  ;
+
+$t->get_ok('/settings/oauth')
+  ->text_is('form.form-table legend', 'Register new client application')
+  ->attr_is('form.oauth-register','action', '/settings/oauth/register')
+  ;
+
+$csrf = $t->post_ok('/settings/oauth/register' => form => {
+  name => 'MyApp',
+  type => 'PUBLIC',
+  desc => 'This is my application'
+})
+  ->text_is('div.notify-error', 'Bad CSRF token')
+  ->tx->res->dom->at('input[name="csrf_token"]')
+  ->attr('value')
+  ;
+
+$t->post_ok('/settings/oauth/register' => form => {
+  name => 'MyApp',
+  type => 'CONFIDENTIAL',
+  desc => 'This is my application',
+  csrf_token => $csrf
+})
+  ->status_is(200)
+  ->element_exists('div.notify-success')
+  ->text_is('legend', 'Client Credentials')
+  ->text_is('label[for=client_id]', 'ID of the client application')
+  ->element_exists('input[name=client_id][readonly][value]')
+  ->element_exists('input[name=client_secret][readonly][value]')
+  ;
 
 done_testing;
 __END__
diff --git a/t/server/mock.pl b/t/server/mock.pl
index a1f0596..21192f0 100644
--- a/t/server/mock.pl
+++ b/t/server/mock.pl
@@ -15,10 +15,12 @@
 my $fixture_path = path(Mojo::File->new(__FILE__)->dirname)->child('..', 'fixtures');
 
 our %tokens = (
-  "access_token"    => "4dcf8784ccfd26fac9bdb82778fe60e2",
-  "refresh_token"   => "hlWci75xb8atDiq3924NUSvOdtAh7Nlf9z",
-  "access_token_2"  => "abcde",
-  "refresh_token_2" => "fghijk"
+  'access_token'    => "4dcf8784ccfd26fac9bdb82778fe60e2",
+  'refresh_token'   => "hlWci75xb8atDiq3924NUSvOdtAh7Nlf9z",
+  'access_token_2'  => "abcde",
+  'refresh_token_2' => "fghijk",
+  'new_client_id' => 'fCBbQkA2NDA3MzM1Yw==',
+  'new_client_secret' => 'KUMaFxs6R1WGud4HM22w3HbmYKHMnNHIiLJ2ihaWtB4N5JxGzZgyqs5GTLutrORj',
 );
 
 helper get_token => sub {
@@ -487,6 +489,31 @@
   )
 };
 
+# Register a client
+post '/v1.0/oauth2/client/register' => sub {
+  my $c = shift;
+  my $json = $c->req->json;
+
+  my $name = $json->{name};
+  my $desc = $json->{desc};
+  my $type = $json->{type};
+  my $url  = $json->{url};
+  my $redirect_url = $json->{redirectURI};
+
+  # Confidential server application
+  if ($type eq 'CONFIDENTIAL') {
+    return $c->render(json => {
+      client_id => $tokens{new_client_id},
+      client_secret => $tokens{new_client_secret}
+    });
+  };
+
+  # Desktop application
+  return $c->render(json => {
+    client_id => $tokens{new_client_id}
+  });
+};
+
 
 app->start;
 
diff --git a/t/settings.t b/t/settings.t
index 9620504..9eb0655 100644
--- a/t/settings.t
+++ b/t/settings.t
@@ -31,21 +31,15 @@
   }
 });
 
-my $app = $t->app;
-
-$app->routes->get('/settings')->to(cb => sub { shift->render('settings') })->name('settings_start');
-$app->routes->get('/settings/:scope/:page')->to(scope => undef, page => undef)->name('settings');
-
-
 $t->get_ok('/settings')
   ->text_is('a[href~/settings/oauth]','OAuth Token Management')
-  ->text_is('h2#page-top', 'Settings')
+  ->text_is('h1 span', 'Settings')
   ;
 
 $t->get_ok('/settings/oauth')
   ->text_is('a[href~/settings/oauth]','OAuth Token Management')
-  ->text_is('h2#page-top', 'Settings')
-  ->text_is('#abc', 'My Settings')
+  ->text_is('h1 span', 'Settings')
+  ->text_is('p#abc', 'My Settings')
   ;
 
 done_testing;
diff --git a/templates/settings.html.ep b/templates/settings.html.ep
index bd92eb6..17f1be5 100644
--- a/templates/settings.html.ep
+++ b/templates/settings.html.ep
@@ -12,6 +12,4 @@
 
 % layout 'main', sidebar_active => 1, main_class => 'page settings';
 
-%= page_title
-
 %= content 'settings'