OAuth client authorization handling (Fixes #54)

Change-Id: I3dd3b995af5e53bc8347818727e9733859eb1af6
diff --git a/Changes b/Changes
index 3131657..3d2d514 100755
--- a/Changes
+++ b/Changes
@@ -1,5 +1,6 @@
-0.45 2022-04-06
+0.45 2022-04-13
         - Added confidential client support to OAuth. (diewald)
+        - Added OAuth client authorization handling. (diewald)
 
 0.44 2022-02-31
         - Fixed autosecrets migration. (diewald)
diff --git a/lib/Kalamar/Plugin/Auth.pm b/lib/Kalamar/Plugin/Auth.pm
index 963300a..4bdb1f3 100644
--- a/lib/Kalamar/Plugin/Auth.pm
+++ b/lib/Kalamar/Plugin/Auth.pm
@@ -60,6 +60,7 @@
       Auth => {
         _ => sub { $_->locale },
         de => {
+          loginPlease => 'Bitte melden Sie sich an!',
           loginSuccess => 'Anmeldung erfolgreich',
           loginFail => 'Anmeldung fehlgeschlagen',
           logoutSuccess => 'Abmeldung erfolgreich',
@@ -103,10 +104,15 @@
             -long => 'Widerrufe einen Token für <span class="client-name"><%= $client_name %></span>',
             short => 'Widerrufe'
           },
+          oauthGrantScope => {
+            -long => '<span class="client-name"><%= $client_name %></span> möchte Zugriffsrechte',
+            short => 'Zugriffsrechte erteilen'
+          },
           createdAt => 'Erstellt am <time datetime="<%= stash("date") %>"><%= stash("date") %></date>.',
           expiresIn => 'Läuft in <%= stash("seconds") %> Sekunden ab.'
         },
         -en => {
+          loginPlease => 'Please log in!',
           loginSuccess => 'Login successful',
           loginFail => 'Access denied',
           logoutSuccess => 'Logout successful',
@@ -150,6 +156,10 @@
             -long => 'Revoke a token for <span class="client-name"><%= $client_name %></span>',
             short => 'Revoke'
           },
+          oauthGrantScope => {
+            -long => '<span class="client-name"><%= $client_name %></span> wants to have access',
+            short => 'Grant access'
+          },
           createdAt => 'Created at <time datetime="<%= stash("date") %>"><%= stash("date") %></date>.',
           expiresIn => 'Expires in <%= stash("seconds") %> seconds.'
         }
@@ -467,6 +477,7 @@
         # If the request already has an Authorization
         # header, respect it!
         if ($h->authorization) {
+
           return $ua->start_p($tx);
         };
 
@@ -500,6 +511,7 @@
             # The token is expired and no refresh token is
             # available - issue an unauthorized request!
             else {
+
               $c->stash(auth => undef);
               $c->stash(auth_exp => undef);
               delete $c->session->{user};
@@ -999,6 +1011,144 @@
       )->name('oauth-unregister-post');
 
 
+      # OAuth Client authorization
+      $r->get('/settings/oauth/authorize')->to(
+        cb => sub {
+          my $c = shift;
+
+          _set_no_cache($c->res->headers);
+
+          my $v = $c->validation;
+          $v->required('client_id');
+          $v->optional('scope');
+          $v->optional('state');
+          $v->optional('redirect_uri');
+
+          # Redirect with error
+          if ($v->has_error) {
+            $c->notify(error => $c->loc('Auth_paramError'));
+            return $c->redirect_to;
+          };
+
+          foreach (qw!scope client_id state redirect_uri!) {
+            $c->stash($_, $v->param($_));
+          };
+
+          # Get auth token
+          my $auth_token = $c->auth->token;
+
+          # TODO: Fetch client information from Server
+          $c->stash(name => $v->param('client_id'));
+          # my $redirect_uri_server = $c->url_for('index')->to_abs;
+          $c->stash(type => 'CONFIDENTIAL');
+
+          $c->stash(redirect_uri_server => $c->stash('redirect_uri'));
+
+          # User is not logged in - log in before!
+          unless ($auth_token) {
+            return $c->render(template => 'auth/login');
+          };
+
+          # Grant authorization
+          return $c->render(template => 'auth/grant_scope');
+        }
+      )->name('oauth-grant-scope');
+
+
+      # OAuth Client authorization
+      # This will return a location information including some info
+      $r->post('/settings/oauth/authorize')->to(
+        cb => sub {
+          my $c = shift;
+
+          _set_no_cache($c->res->headers);
+
+          # It's necessary that it's clear this was triggered by
+          # KorAP and not by the client!
+          my $v = $c->validation;
+          $v->csrf_protect;
+          $v->required('client_id');
+          $v->optional('scope');
+          $v->optional('state');
+          $v->optional('redirect_uri');
+
+          # WARN! SIGN THIS TO PREVENT OPEN REDIRECT ATTACKS!
+          $v->required('redirect_uri_server');
+
+          # Render with error
+          if ($v->has_error) {
+            my $url = Mojo::URL->new($v->param('redirect_uri_server') // $c->url_for('index'));
+
+            if ($v->has_error('csrf_token')) {
+              $url->query([error_description => $c->loc('Auth_csrfFail')]);
+            }
+            else {
+              $url->query([error_description => $c->loc('Auth_paramError')]);
+            };
+
+            return $c->redirect_to($url);
+          };
+
+          state $r_url = Mojo::URL->new($c->korap->api)->path('oauth2/authorize');
+          $c->stash(redirect_uri_server => Mojo::URL->new($v->param('redirect_uri_server')));
+
+          return $c->korap_request(post => $r_url, {} => form => {
+            response_type => 'code',
+            client_id => $v->param('client_id'),
+            redirect_uri => $v->param('redirect_uri'),
+            state => $v->param('state'),
+            scope => $v->param('scope'),
+          })->then(
+            sub {
+              my $tx = shift;
+
+              # Check for location header with code in redirects
+              my $loc;
+              foreach (@{$tx->redirects}) {
+                $loc = $_->res->headers->header('Location');
+
+                my $url = Mojo::URL->new($loc);
+
+                if ($url->query->param('code')) {
+                  last;
+                } elsif (my $err = $url->query->param('error_description')) {
+                  return Mojo::Promise->reject($err);
+                }
+              };
+
+              return Mojo::Promise->resolve($loc) if $loc;
+
+              # Failed redirect, but location set
+              if ($tx->res->headers->location) {
+                my $url = Mojo::URL->new($tx->res->headers->location);
+                if (my $err = $url->query->param('error_description'))  {
+                  return Mojo::Promise->reject($err);
+                };
+              };
+
+              # No location code
+              return Mojo::Promise->reject('no location response');
+            }
+          )->catch(
+            sub {
+              my $err_msg = shift;
+              my $url = $c->stash('redirect_uri_server');
+              if ($err_msg) {
+                $url = $url->query([error_description => $err_msg]);
+              };
+              return Mojo::Promise->resolve($url);
+            }
+          )->then(
+            sub {
+              my $loc = shift;
+              return $c->redirect_to($loc);
+            }
+          )->wait;
+          return $c->rendered;
+        }
+      )->name('oauth-grant-scope-post');
+
+
       # Show information of a client
       $r->get('/settings/oauth/:client_id')->to(
         cb => sub {
diff --git a/lib/Kalamar/Plugin/Auth/templates/auth/grant_scope.html.ep b/lib/Kalamar/Plugin/Auth/templates/auth/grant_scope.html.ep
new file mode 100644
index 0000000..ba58500
--- /dev/null
+++ b/lib/Kalamar/Plugin/Auth/templates/auth/grant_scope.html.ep
@@ -0,0 +1,25 @@
+% extends 'settings', title => 'KorAP: ' . loc('Auth_oauthSettings'), page => 'oauth';
+
+%= page_title
+
+<p><%== loc('Auth_oauthGrantScope', client_name => stash('name')) %></p>
+
+%= form_for 'oauth-grant-scope-post', id => 'grant-scope', class => 'form-table', begin
+   %= csrf_field
+   %= hidden_field 'client_id' => stash('client_id')
+   %= hidden_field 'name' => stash('name')
+   %= hidden_field 'state' => stash('state')
+   %= hidden_field 'redirect_uri' => stash('redirect_uri')
+   %= hidden_field 'redirect_uri_server' => stash('redirect_uri_server')
+   % if (stash('scope')) {
+   <ul id="scopes">
+   %   foreach (split(/\s+/, stash('scope'))) {
+     <li><%= $_ %></li>
+   %   };
+   </ul>
+   %= hidden_field 'scope' => stash('scope')
+   % };
+
+   <input type="submit" value="<%= loc 'Auth_oauthGrantScope_short' %>" />
+   %= link_to loc('abort') => stash('redirect_uri_server') => {} => (class => 'form-button button-abort')
+% end
diff --git a/lib/Kalamar/Plugin/Auth/templates/auth/login.html.ep b/lib/Kalamar/Plugin/Auth/templates/auth/login.html.ep
new file mode 100644
index 0000000..4801e29
--- /dev/null
+++ b/lib/Kalamar/Plugin/Auth/templates/auth/login.html.ep
@@ -0,0 +1,6 @@
+% layout 'main', login_active => 1;
+
+<div class="intro">
+  <p><%== loc('Auth_oauthGrantScope', client_name => stash('name')) %></p>
+  <p><%== loc('Auth_loginPlease') %></p>
+</div>
diff --git a/lib/Kalamar/Plugin/Auth/templates/partial/auth/login.html.ep b/lib/Kalamar/Plugin/Auth/templates/partial/auth/login.html.ep
index 6be3c5c..66dee42 100644
--- a/lib/Kalamar/Plugin/Auth/templates/partial/auth/login.html.ep
+++ b/lib/Kalamar/Plugin/Auth/templates/partial/auth/login.html.ep
@@ -12,6 +12,13 @@
       %= csrf_field
       %= text_field 'handle', placeholder => loc('username')
       %= hidden_field fwd => $c->url_with
+      % if (stash('client_id')) {
+        %= hidden_field 'client_id' => stash('client_id')
+        %= hidden_field 'name' => stash('name')
+        %= hidden_field 'state' => stash('state')
+        %= hidden_field 'scope' => stash('scope')
+        %= hidden_field 'redirect_uri' => stash('redirect_uri')
+      % };
       <div>
         %= password_field 'pwd', placeholder => loc('pwd')
         <button type="submit"><span><%= loc 'go' %></span></button>
diff --git a/lib/Kalamar/Plugin/KalamarPages.pm b/lib/Kalamar/Plugin/KalamarPages.pm
index d1ad118..9596ad0 100644
--- a/lib/Kalamar/Plugin/KalamarPages.pm
+++ b/lib/Kalamar/Plugin/KalamarPages.pm
@@ -34,6 +34,15 @@
       ($page, my $fragment) = split '#', $page;
 
       my $url = $c->url_with($realm, page => $page, scope => $scope);
+      my $p = $url->query;
+
+      # Remove oauth-specific psarameters
+      # (Maybe only allowing specific parameters is better though)
+      $p->remove('client_id')
+        ->remove('client_secret')
+        ->remove('state')
+        ->remove('scope')
+        ->remove('redirect_uri');
       $url->fragment($fragment) if $fragment;
       $url->path->canonicalize;
 
diff --git a/t/plugin/auth-oauth.t b/t/plugin/auth-oauth.t
index e8ac795..e12c394 100644
--- a/t/plugin/auth-oauth.t
+++ b/t/plugin/auth-oauth.t
@@ -752,6 +752,133 @@
   ->text_is('div.notify-error', 'invalid_request: http://localhost/FAIL is invalid.')
   ;
 
+# OAuth client authorization flow
+$t->get_ok(Mojo::URL->new('/settings/oauth/authorize'))
+  ->status_is(302)
+  ->header_is('location','/settings/oauth/authorize')
+  ;
+
+# Logout
+$t->get_ok('/x/expired-with-wrong-refresh');
+
+$t->get_ok('/user/logout')
+  ->status_is(302)
+  ->session_hasnt('/auth')
+  ->session_hasnt('/auth_r')
+  ->session_hasnt('/user')
+  ->header_is('Location' => '/');
+
+$csrf = $t->get_ok('/')
+  ->status_is(200)
+  ->element_exists_not('div.notify-error')
+  ->element_exists('div.notify-success')
+  ->text_is('div.notify-success', 'Logout successful')
+  ->element_exists("input[name=handle]")
+  ->tx->res->dom->at('input[name=csrf_token]')->attr('value')
+  ;
+
+$fwd = $t->get_ok(Mojo::URL->new('/settings/oauth/authorize')->query({
+  client_id => 'xyz',
+  state => 'abcde',
+  scope => 'search match',
+  redirect_uri => 'http://test.com/',
+}))
+  ->status_is(200)
+  ->attr_is('input[name=client_id]','value','xyz')
+  ->attr_is('input[name=state]','value','abcde')
+  ->attr_is('input[name=name]','value','xyz')
+  ->attr_like('input[name=fwd]','value',qr!test\.com!)
+  ->text_is('span.client-name','xyz')
+  ->text_is('div.intro p:nth-child(2)', 'Please log in!')
+  ->tx->res->dom->at('input[name=fwd]')->attr('value')
+  ;
+
+$fwd = $t->post_ok(Mojo::URL->new('/user/login')->query({
+  csrf_token => $csrf,
+  client_id => 'xyz',
+  state => 'abcde',
+  scope => 'search match',
+  redirect_uri => 'http://test.com/',
+  handle => 'test',
+  pwd => 'pass',
+  fwd => $fwd
+}))
+  ->status_is(302)
+  ->header_like('location', qr!/settings/oauth/authorize!)
+  ->tx->res->headers->header('location')
+  ;
+
+$t->get_ok($fwd)
+  ->status_is(200)
+  ->attr_is('input[name=client_id]','value','xyz')
+  ->attr_is('input[name=state]','value','abcde')
+  ->attr_is('input[name=name]','value','xyz')
+  ->text_is('ul#scopes li:nth-child(1)','search')
+  ->text_is('ul#scopes li:nth-child(2)','match')
+  ->text_is('span.client-name','xyz')
+  ->attr_is('a.form-button','href','http://test.com/')
+  ->attr_is('a.embedded-link', 'href', '/doc/korap/kalamar')
+  ;
+
+$t->get_ok(Mojo::URL->new('/settings/oauth/authorize')->query({
+  client_id => 'xyz',
+  state => 'abcde',
+  scope => 'search match',
+  redirect_uri => 'http://test.com/'
+}))
+  ->status_is(200)
+  ->attr_is('input[name=client_id]','value','xyz')
+  ->attr_is('input[name=state]','value','abcde')
+  ->attr_is('input[name=name]','value','xyz')
+  ->text_is('ul#scopes li:nth-child(1)','search')
+  ->text_is('ul#scopes li:nth-child(2)','match')
+  ->text_is('span.client-name','xyz')
+  ->attr_is('a.form-button','href','http://test.com/')
+  ->attr_is('a.embedded-link', 'href', '/doc/korap/kalamar')
+  ;
+
+$t->post_ok(Mojo::URL->new('/settings/oauth/authorize')->query({
+  client_id => 'xyz',
+  state => 'abcde',
+  scope => 'search match',
+  redirect_uri => 'http://test.com/'
+}))
+  ->status_is(302)
+  ->header_is('location', '/?error_description=Bad+CSRF+token')
+  ;
+
+$fwd = $t->post_ok(Mojo::URL->new('/settings/oauth/authorize')->query({
+  client_id => 'xyz',
+  state => 'abcde',
+  scope => 'search match',
+  redirect_uri_server => 'http://example.com/',
+  redirect_uri => $fake_backend_app->url_for('return_uri')->to_abs,
+  csrf_token => $csrf,
+}))
+  ->status_is(302)
+  ->header_like('location', qr!/realapi/fakeclient/return!)
+  ->tx->res->headers->header('location')
+  ;
+
+$t->get_ok($fwd)
+  ->status_is(200)
+  ->content_like(qr'welcome back! \[(.+?)\]')
+  ;
+
+$t->post_ok(Mojo::URL->new('/settings/oauth/authorize')->query({
+  client_id => 'xyz',
+  state => 'fail',
+  scope => 'search match',
+  redirect_uri_server => 'http://example.com/',
+  redirect_uri => $fake_backend_app->url_for('return_uri')->to_abs,
+  csrf_token => $csrf,
+}))
+  ->status_is(302)
+  ->header_is('location', 'http://example.com/?error_description=FAIL')
+  ;
+
 
 done_testing;
 __END__
+
+
diff --git a/t/server/mock.pl b/t/server/mock.pl
index d7ec2ff..7deb12a 100644
--- a/t/server/mock.pl
+++ b/t/server/mock.pl
@@ -669,9 +669,31 @@
   my $c = shift;
   my $type = $c->param('response_type');
   my $client_id = $c->param('client_id');
-  my $redirect_uri = $c->param('redirect_uri');
+  my $scope = $c->param('scope');
+  my $state = $c->param('state');
+  my $redirect_uri = $c->param('redirect_uri') // 'NO';
 
-  if ($type eq 'code') {
+  if ($type eq 'code' && $client_id eq 'xyz') {
+
+    if ($state eq 'fail') {
+      $c->res->headers->location(
+        Mojo::URL->new($redirect_uri)->query({
+          error_description => 'FAIL'
+        })
+        );
+      $c->res->code(400);
+      return $c->rendered;
+    };
+
+    return $c->redirect_to(
+      Mojo::URL->new($redirect_uri)->query({
+        code => $tokens{auth_token_1},
+        scope => $scope,
+      })
+      );
+  }
+
+  elsif ($type eq 'code') {
 
     return $c->redirect_to(
       Mojo::URL->new($redirect_uri)->query({
@@ -812,6 +834,13 @@
   return $c->render(text => 'SUCCESS');
 };
 
+get '/fakeclient/return' => sub {
+  my $c = shift;
+  $c->render(
+    text => 'welcome back! [' . $c->param('code') . ']'
+  );
+} => 'return_uri';
+
 
 app->start;