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;