1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195
|
use Mojo::Base -strict;
use Test::More;
use Test::Mojo;
use MIME::Base64 qw(encode_base64url);
use Mojo::JSON qw(decode_json encode_json);
use Mojo::URL;
use Mojolicious::Plugin::OAuth2;
plan skip_all => "Mojo::JWT, Crypt::OpenSSL::RSA and Crypt::OpenSSL::Bignum required for openid tests"
unless Mojolicious::Plugin::OAuth2::MOJO_JWT;
use Mojolicious::Lite;
plugin OAuth2 => {mocked =>
{key => 'c0e71b99-2c66-42e7-8589-6502153a7e3', well_known_url => '/mocked/oauth2/.well-known/configuration'}};
get '/' => sub { shift->render('index') };
any '/connect' => sub {
my $c = shift;
$c->render_later;
my $get_token_args = {
redirect_uri => $c->req->url->to_abs,
authorize_query => {
response_mode => $ENV{'OAUTH2_MOCK_RESPONSE_MODE'} // 'form_post',
response_type => 'code',
state => $c->param('oauth2.state') // 'test'
}
};
$c->oauth2->get_token_p(mocked => $get_token_args)->then(sub {
return unless my $provider_res = shift; # Redirect to IdP
$c->session(token => $provider_res->{access_token}, refresh_token => $provider_res->{refresh_token});
my $user = $c->oauth2->jwt_decode(mocked => data => $provider_res->{id_token});
$c->signed_cookie(id_token => $provider_res->{id_token});
return $c->redirect_to($c->param('state')) if $c->param('state') ne 'test';
$c->render(json => $user);
})->catch(sub {
$c->render(text => "Error $_[0]", status => 500);
});
};
# exercise end_session_endpoint
get '/end_session' => sub {
my $c = shift;
my $home = $c->req->url->base->clone->tap(path => '/');
# require id_token to calculate logout_url
return $c->redirect_to($home) unless my $id_token = $c->signed_cookie('id_token');
my $end_session_url = $c->oauth2->logout_url(
mocked => {post_logout_redirect_uri => $c->req->url->to_abs, id_token_hint => $id_token, state => time});
return $c->redirect_to($end_session_url) unless $c->param('id_token_hint') and my $state = $c->param('state');
$c->signed_cookie('id_token' => $id_token, {expires => time - 1});
delete $c->session->{$_} for (qw(token refresh_token));
return $c->redirect_to($home);
};
# refresh access token using refresh_token
get '/refresh' => sub {
my $c = shift;
$c->render_later;
$c->oauth2->get_refresh_token_p(mocked => {refresh_token => $c->session('refresh_token') . '+'})->then(sub {
my $res = shift;
$c->session(refresh_token => $res->{refresh_token});
$c->render(json => $res);
})->catch(sub { $c->render(text => "Error $_[0]", status => 500); });
};
group {
under '/protect' => sub {
my $c = shift;
Mojo::IOLoop->timer(
0.1 => sub {
$c->redirect_to($c->url_for('connect')->query({'oauth2.state' => $c->req->url}));
}
);
return undef unless $c->session('token');
return 1;
};
get '/next' => sub { shift->render(text => 'ok') };
};
my $t = Test::Mojo->new;
subtest 'warmup of provider data' => sub {
my $provider_conf = $t->app->oauth2->providers->{mocked};
is $provider_conf->{scope}, 'openid', 'scope';
is $provider_conf->{userinfo_url}, undef, 'userinfo_url';
ok $provider_conf->{jwt}, 'resolved from configuration';
ok +Mojo::URL->new($provider_conf->{$_})->scheme, $_ for qw(authorize_url end_session_url issuer token_url);
};
subtest 'Authorize and obtain token - form_post response_mode' => sub {
$t->get_ok('/connect')->status_is(302);
my $location = Mojo::URL->new($t->tx->res->headers->location);
is $location->query->param('scope'), 'openid', 'scope set';
is $location->query->param('response_mode'), 'form_post', 'response mode set';
my ($action, $form);
$t->get_ok($location)->status_is(200)->tap(sub {
my $dom = shift->tx->res->dom;
$action = $dom->at('form')->attr('action');
$form
= {code => $dom->at('input[name=code]')->attr('value'), state => $dom->at('input[name=state]')->attr('value')};
ok +Mojo::URL->new($action)->is_abs, 'absolute url';
});
$t->post_ok($action, form => $form)->status_is(200)->json_is('/aud' => 'c0e71b99-2c66-42e7-8589-6502153a7e3')
->json_is('/email' => 'foo.bar@example.com')
->json_is('/iss' => $t->app->oauth2->providers->{mocked}{issuer}, 'OIDC valid (MUST)')
->json_is('/name' => 'foo bar')->json_is('/preferred_username' => 'foo.bar@example.com')
->json_is('/sub' => 'foo.bar')->json_has('/iat')->json_has('/exp');
};
subtest 'Refresh token' => sub {
$t->get_ok('/refresh')->status_is(200)->json_is('/refresh_token', 'refresh-token+');
$t->get_ok('/refresh')->status_is(200)->json_is('/refresh_token', 'refresh-token++');
$t->get_ok('/refresh?error=bad')->status_is(500)->content_is('Error bad');
};
subtest 'Authorize and obtain token - query response_mode' => sub {
local $ENV{OAUTH2_MOCK_RESPONSE_MODE} = 'query';
local $t->app->oauth2->providers->{mocked}{scope} = 'openid email profile';
$t->get_ok('/connect')->status_is(302);
my $location = Mojo::URL->new($t->tx->res->headers->location);
is $location->query->param('scope'), 'openid email profile', 'scope set';
is $location->query->param('response_mode'), 'query', 'response mode set';
is $location->query->param('state'), 'test', 'state propagates';
my ($action, $form);
$t->get_ok($location)->status_is(302);
$location = Mojo::URL->new($t->tx->res->headers->location);
is $location->path, '/connect', 'redirect_uri';
is $location->query->param('code'), 'authorize-code', 'code set';
is $location->query->param('state'), 'test', 'state returned';
$t->get_ok("$location")->status_is(200)->json_is('/aud' => 'c0e71b99-2c66-42e7-8589-6502153a7e3')
->json_is('/email' => 'foo.bar@example.com')
->json_is('/iss' => $t->app->oauth2->providers->{mocked}{issuer}, 'OIDC valid (MUST)')
->json_is('/name' => 'foo bar')->json_is('/preferred_username' => 'foo.bar@example.com')
->json_is('/sub' => 'foo.bar')->json_has('/iat')->json_has('/exp');
};
subtest 'Logout' => sub {
my $end_session_url = $t->ua->server->url->clone->tap(path => '/end_session');
# obtain signed cookie from user agent
my $c = $t->app->build_controller->tap(sub { $_->tx->req->cookies(@{$t->ua->cookie_jar->all}) });
my $id_token = $c->signed_cookie('id_token');
ok $id_token, 'Have a current id token';
$t->get_ok($end_session_url)->status_is(302);
my $op_location = Mojo::URL->new($t->tx->res->headers->location);
is $op_location->path, '/mocked/oauth2/logout', 'correct';
is $op_location->query->param('id_token_hint'), $id_token, 'correct id token';
is $op_location->query->param('post_logout_redirect_uri'), $end_session_url, 'post_logout_redirect_uri set';
is $op_location->query->param('state'), time, 'state set';
$t->get_ok($op_location)->status_is(302);
my $rp_location = Mojo::URL->new($t->tx->res->headers->location);
is $rp_location->path, '/end_session', 'correct';
is $rp_location->query->param('id_token_hint'), $id_token, 'correct id token';
is $rp_location->query->param('state'), time, 'state set';
$t->get_ok($rp_location)->status_is(302);
my $logged_out = Mojo::URL->new($t->tx->res->headers->location);
is $logged_out->path, '/', 'home';
my @cookies = grep { $_->name eq 'id_token' } @{$t->ua->cookie_jar->find($end_session_url) || []};
is_deeply \@cookies, [], 'removed';
$t->get_ok($end_session_url)->status_is(302);
$logged_out = Mojo::URL->new($t->tx->res->headers->location);
is $logged_out->path, '/', 'home';
};
subtest 'Redirects with under' => sub {
local $ENV{OAUTH2_MOCK_RESPONSE_MODE} = 'query';
my $max = $t->ua->max_redirects;
my $url = $t->ua->server->url->clone->tap(path => '/protect/next');
$t->reset_session->ua->max_redirects($max + 5);
$t->get_ok('/protect/next')->status_is(200)->content_is('ok');
is_deeply [map { $_->name } grep { $_->name eq 'id_token' } @{$t->ua->cookie_jar->find($url) || []}], ['id_token'],
'set cookie';
is_deeply [map { $_->req->url->path } @{$t->tx->redirects}],
[qw(/protect/next /connect /mocked/oauth2/authorize /connect)], 'login chain';
$t->ua->max_redirects($max);
};
done_testing;
__DATA__
@@ index.html.ep
%= link_to 'Connect', $c->url_for('connect');
|