File Coverage

blib/lib/Dancer2/Plugin/OAuth2/Server.pm
Criterion Covered Total %
statement 105 118 88.9
branch 31 40 77.5
condition 28 42 66.6
subroutine 14 14 100.0
pod 0 1 0.0
total 178 215 82.7


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::OAuth2::Server;
2              
3 5     5   4488359 use strict;
  5         52  
  5         153  
4 5     5   33 use warnings;
  5         10  
  5         127  
5 5     5   145 use 5.008_005;
  5         21  
6             our $VERSION = '0.11';
7 5     5   2907 use Dancer2::Plugin;
  5         370626  
  5         52  
8 5     5   68672 use URI;
  5         4867  
  5         160  
9 5     5   1002 use URI::QueryParam;
  5         1757  
  5         171  
10 5     5   2620 use Class::Load qw(try_load_class);
  5         71251  
  5         366  
11 5     5   47 use Carp;
  5         11  
  5         7850  
12              
13             sub get_server_class {
14 31     31 0 79 my ($settings) = @_;
15 31   50     159 my $server_class = $settings->{server_class}//"Dancer2::Plugin::OAuth2::Server::Simple";
16 31         124 my ($ok, $error) = try_load_class($server_class);
17 31 50       1074 if (! $ok) {
18 0         0 confess "Cannot load server class $server_class: $error";
19             }
20              
21 31         131 return $server_class->new();
22             }
23              
24             on_plugin_import {
25             my $dsl = shift;
26             my $settings = plugin_setting;
27             my $authorization_route = $settings->{authorize_route}//'/oauth/authorize';
28             my $access_token_route = $settings->{access_token_route}//'/oauth/access_token';
29              
30             $dsl->app->add_route(
31             method => 'get',
32             regexp => $authorization_route,
33             code => sub { _authorization_request( $dsl, $settings ) }
34             );
35             $dsl->app->add_route(
36             method => 'post',
37             regexp => $access_token_route,
38             code => sub { _access_token_request( $dsl, $settings ) }
39             );
40             };
41              
42             register 'oauth_scopes' => sub {
43 2     2   137 my ($dsl, $scopes, $code_ref) = @_;
44              
45 2         11 my $settings = plugin_setting;
46              
47 2 50       235 $scopes = [$scopes] unless ref $scopes eq 'ARRAY';
48              
49             return sub {
50 8     8   52869 my $server = get_server_class( $settings );
51 8         137 my @res = _verify_access_token_and_scope( $dsl, $settings, $server,0, @$scopes );
52 8 100       35 if( not $res[0] ) {
53 3         15 $dsl->status( 400 );
54 3         326 return $dsl->send_as( JSON => { error => $res[1] } );
55             } else {
56 5         42 $dsl->app->request->var( oauth_access_token => $res[0] );
57 5         70 goto $code_ref;
58             }
59             }
60 2         19 };
61              
62             sub _authorization_request {
63 15     15   53 my ($dsl, $settings) = @_;
64             my ( $c_id,$url,$type,$scope,$state )
65 15   100     112 = map { $dsl->param( $_ ) // undef }
  75         1770  
66             qw/ client_id redirect_uri response_type scope state /;
67              
68 15         381 my $server = get_server_class( $settings );
69              
70 15 100       365 my @scopes = $scope ? split( / /,$scope ) : ();
71              
72 15 100 66     146 if (
      66        
73             ! defined( $c_id )
74             or ! defined( $type )
75             or $type ne 'code'
76             ) {
77 4         27 $dsl->status( 400 );
78 4         636 return $dsl->send_as(
79             JSON => {
80             error => 'invalid_request',
81             error_description => 'the request was missing one of: client_id, '
82             . 'response_type;'
83             . 'or response_type did not equal "code"',
84             error_uri => '',
85             }
86             );
87             }
88              
89 11   100     51 my $state_required = $settings->{state_required} // 0;
90 11 100 100     48 if(
      66        
91             $state_required
92             and ! defined $state
93             and ! length $state
94             ) {
95 1         5 $dsl->status( 400 );
96 1         120 return $dsl->send_as(
97             JSON => {
98             error => 'invalid_request',
99             error_description => 'the request was missing : state ',
100             error_uri => '',
101             }
102             );
103             }
104              
105 10         75 my $uri = URI->new( $url );
106 10         902 my ( $res,$error ) = $server->verify_client($dsl, $settings, $c_id, \@scopes, $url );
107              
108 10 100       39 if ( $res ) {
109 6 50       26 if ( ! $server->login_resource_owner( $dsl, $settings ) ) {
110 0         0 $dsl->debug( "OAuth2::Server: Resource owner not logged in" );
111             # call to $resource_owner_logged_in method should have called redirect_to
112 0         0 return;
113             } else {
114 6         25 $dsl->debug( "OAuth2::Server: Resource owner is logged in" );
115 6         1645 $res = $server->confirm_by_resource_owner($dsl, $settings, $c_id, \@scopes );
116 6 50       33 if ( ! defined $res ) {
    50          
117 0         0 $dsl->debug( "OAuth2::Server: Resource owner to confirm scopes" );
118             # call to $resource_owner_confirms method should have called redirect_to
119 0         0 return;
120             }
121             elsif ( $res == 0 ) {
122 0         0 $dsl->debug( "OAuth2::Server: Resource owner denied scopes" );
123 0         0 $error = 'access_denied';
124             }
125             }
126             }
127              
128 10 100       43 if ( $res ) {
    50          
129 6         33 $dsl->debug( "OAuth2::Server: Generating auth code for $c_id" );
130 6   50     1556 my $expires_in = $settings->{auth_code_ttl} // 600;
131              
132 6         37 my $auth_code = $server->generate_token($dsl, $settings, $expires_in, $c_id, \@scopes, 'auth', $url );
133              
134 6         37 $server->store_auth_code($dsl, $settings, $auth_code,$c_id,$expires_in,$url,@scopes );
135              
136 6         43 $uri->query_param_append( code => $auth_code );
137              
138             } elsif ( $error ) {
139 4         41 $uri->query_param_append( error => $error );
140             } else {
141             # callback has not returned anything, assume server error
142 0         0 $uri->query_param_append( error => 'server_error' );
143 0         0 $uri->query_param_append( error_description => 'call to verify_client returned unexpected value' );
144             }
145              
146 10 100       1048 $uri->query_param_append( state => $state ) if defined( $state );
147              
148 10         781 $dsl->redirect( $uri );
149             }
150              
151             sub _access_token_request {
152 8     8   29 my ($dsl, $settings) = @_;
153             my ( $client_id,$client_secret,$grant_type,$auth_code,$url,$refresh_token )
154 8   100     26 = map { $dsl->param( $_ ) // undef }
  48         1024  
155             qw/ client_id client_secret grant_type code redirect_uri refresh_token /;
156              
157 8         221 my $server = get_server_class( $settings );
158              
159 8 50 66     205 if (
      66        
      66        
      33        
      66        
      33        
160             ! defined( $grant_type )
161             or ( $grant_type ne 'authorization_code' and $grant_type ne 'refresh_token' )
162             or ( $grant_type eq 'authorization_code' and ! defined( $auth_code ) )
163             or ( $grant_type eq 'authorization_code' and ! defined( $url ) )
164             ) {
165 2         12 $dsl->status( 400 );
166 2         244 return $dsl->send_as(
167             JSON => {
168             error => 'invalid_request',
169             error_description => 'the request was missing one of: grant_type, '
170             . 'client_id, client_secret, code, redirect_uri;'
171             . 'or grant_type did not equal "authorization_code" '
172             . 'or "refresh_token"',
173             error_uri => '',
174             }
175             );
176 0         0 return;
177             }
178              
179 6         19 my $json_response = {};
180 6         13 my $status = 400;
181 6         14 my ( $client,$error,$scope,$old_refresh_token,$user_id );
182              
183 6 100       16 if ( $grant_type eq 'refresh_token' ) {
184 1         5 ( $client,$error,$scope,$user_id ) = _verify_access_token_and_scope(
185             $dsl, $settings, $server, $refresh_token
186             );
187 1         5 $old_refresh_token = $refresh_token;
188             } else {
189 5         37 ( $client,$error,$scope,$user_id ) = $server->verify_auth_code(
190             $dsl, $settings, $client_id,$client_secret,$auth_code,$url
191             );
192             }
193              
194 6 100       24 if ( $client ) {
    50          
195              
196 5         29 $dsl->debug( "OAuth2::Server: Generating access token for $client" );
197              
198 5   50     1411 my $expires_in = $settings->{access_token_ttl} // 3600;
199 5         50 my $access_token = $server->generate_token($dsl, $settings, $expires_in,$client,$scope,'access',undef,$user_id );
200 5         24 my $refresh_token = $server->generate_token($dsl, $settings, undef,$client,$scope,'refresh',undef,$user_id );
201              
202 5         37 $server->store_access_token(
203             $dsl, $settings,
204             $client,$auth_code,$access_token,$refresh_token,
205             $expires_in,$scope,$old_refresh_token
206             );
207              
208 5         9 $status = 200;
209 5         24 $json_response = {
210             access_token => $access_token,
211             token_type => 'Bearer',
212             expires_in => $expires_in,
213             refresh_token => $refresh_token,
214             };
215              
216             } elsif ( $error ) {
217 1         5 $json_response->{error} = $error;
218             } else {
219             # callback has not returned anything, assume server error
220 0         0 $json_response = {
221             error => 'server_error',
222             error_description => 'call to verify_auth_code returned unexpected value',
223             };
224             }
225              
226 6         36 $dsl->response_header( 'Cache-Control' => 'no-store' );
227 6         1294 $dsl->response_header( 'Pragma' => 'no-cache' );
228              
229 6         517 $dsl->status( $status );
230 6         634 return $dsl->send_as( JSON => $json_response );
231             }
232              
233             sub _verify_access_token_and_scope {
234 9     9   32 my ($dsl, $settings, $server, $refresh_token, @scopes) = @_;
235              
236 9         18 my $access_token;
237              
238 9 100       26 if ( ! $refresh_token ) {
239 8 100       47 if ( my $auth_header = $dsl->app->request->header( 'Authorization' ) ) {
240 7         1395 my ( $auth_type,$auth_access_token ) = split( / /,$auth_header );
241              
242 7 50       27 if ( $auth_type ne 'Bearer' ) {
243 0         0 $dsl->debug( "OAuth2::Server: Auth type is not 'Bearer'" );
244 0         0 return ( 0,'invalid_request' );
245             } else {
246 7         18 $access_token = $auth_access_token;
247             }
248             } else {
249 1         176 $dsl->debug( "OAuth2::Server: Authorization header missing" );
250 1         358 return ( 0,'invalid_request' );
251             }
252             } else {
253 1         4 $access_token = $refresh_token;
254             }
255              
256 8         54 return $server->verify_access_token($dsl, $settings, $access_token,\@scopes,$refresh_token );
257             }
258              
259             register_plugin;
260              
261             1;
262             __END__
263              
264             =encoding utf-8
265              
266             =head1 NAME
267              
268             Dancer2::Plugin::OAuth2::Server - Easier implementation of an OAuth2 Authorization
269             Server / Resource Server with Dancer2
270             Port of Mojolicious implementation : https://github.com/Humanstate/mojolicious-plugin-oauth2-server
271              
272             =head1 SYNOPSIS
273              
274             use Dancer2::Plugin::OAuth2::Server;
275              
276             To protect a route, declare it like following:
277              
278             get '/protected' => oauth_scopes 'desired_scope' => sub { ... }
279              
280             =head1 DESCRIPTION
281              
282             Dancer2::Plugin::OAuth2::Server is a port of Mojolicious plugin for OAuth2 server
283             With this plugin, you can implement an OAuth2 Authorization server and Resource server without too much hassle.
284             The Basic flows are implemented, authorization code, access token, refresh token, ...
285              
286             A "simple" implementation is provided with a "in memory" session management, however, it will not work on multi process persistent
287             environment, as each restart will loose all the access/refrest tokens. Token will also not be shared between processes.
288              
289             For a usable implementation in a realistic context, you will need to create a class implementing the Role Dancer2::Plugin::OAuth2::Server::Role,
290             and configure the server_class option in configuration of the plugin. The following methods needs to be implemented:
291              
292             login_resource_owner
293             confirm_by_resource_owner
294             verify_client
295             store_auth_code
296             generate_token
297             verify_auth_code
298             store_access_token
299             verify_access_token
300              
301             On the resource server side, to protect a resource, just use the dsl keyword oauth_scopes with
302             either one scope or the list of scope needed. In case the authorization header provided is not correct,
303             a 400 http code is returned with an erro message.
304             If the Authorization header is correct and the access is granted, the access token information are
305             stored within the var keyword, in oauth_access_token, for the time of the request. You can access the
306             access token information through var('oauth_access_token') within the route code itself.
307              
308             =head1 CONFIGURATION
309              
310             =head2 authorize_route
311              
312             The route that the Client calls to get an authorization code. Defaults to /oauth/authorize
313             The route is accessible through http GET method
314              
315             =head2 access_token_route
316              
317             The route the the Client calls to get an access token. Defaults to/oauth/access_token
318             The route is accessible through http POST method
319              
320             =head2 auth_code_ttl
321              
322             The validity period of the generated authorization code in seconds. Defaults to
323             600 seconds (10 minutes)
324              
325             =head2 access_token_ttl
326              
327             The validity period of the generated access token in seconds. Defaults to 3600
328             seconds (1 hour)
329              
330             =head2 clients
331              
332             list of clients for the simple default implementation
333              
334             clients:
335             client1:
336             client_secret: secret
337             scopes:
338             identity: 1
339             other: 0
340             client2:
341             client_secret: secret2
342             scopes:
343             identity: 1
344             other: 1
345             redirect_uri:
346             - url1
347             - url2
348              
349             Note the clients config is not required if you add the verify_client callback,
350             but is necessary for running the plugin in its simplest form (when no server class
351             is provided). In order to whitelist redirect_uri, provide an entry in the client
352             if no entry is present, all uri are accepted
353              
354             =head2 state_required
355              
356             State is optional in the sepcifications, however using state is really recommended to have a safe implementation on client side.
357             Client should send state and verify it, switching state_required to 1 make state a required parameter when trying to get
358             the authorization code
359              
360             =head2 server_class
361              
362             Package name of the server class for customizing the OAuth server behavior.
363             Defaults to Dancer2::Plugin::OAuth2::Server::Simple, the provided simple implementation
364              
365             =head1 Server Class implementation
366              
367             To customize the implementation in a more realistic way, the user needs to create a class implementing
368             the role Dancer2::Plugin::OAuth2::Server::Role , and provide the Class name in the configuration key
369             server_class. That role ensures that all the required functions are implemented.
370             All the function will receive the dsl and settings as first 2 parameters: $dsl, $settings
371             Those parameters will for instance allows user to access session, are plugin configuration
372              
373             =head2 login_resource_owner
374              
375             Function that tells if the Resource owner is logged in. It should return 1 if the
376             user is logged in, return 0 if not. That function is expected to redirect the user to login page if needed.
377              
378             =head2 confirm_by_resource_owner
379              
380             Function to tell the plugin if the Resource Owner allowed or denied access to
381             the Resource Server by the Client. Function receives the client_id and the list of scopes
382             requested by the client.
383             It should return 1 if access is allowed, 0 if access is not allowed, otherwise
384             it should redirect the user and return undef
385              
386             =head2 verify_client
387              
388             Reference: L<http://tools.ietf.org/html/rfc6749#section-4.1.1>
389              
390             Function to verify if the client asking for an authorization code is known
391             to the Resource Server and allowed to get an authorization code for the passed
392             scopes.
393             The function receives the client id, and an array reference of request scopes, and
394             the redirect url. The callback should return a list with two elements. The first
395             element is either 1 or 0 to say that the client is allowed or disallowed, the second element
396             should be the error message in the case of the client being disallowed.
397             Note: Even if the redirect url is optional, there can be some security concern if
398             someone redirects to a compromised server. Because of that, some OAuth2 provider
399             requried to whitelist the redirect uri by client. To allow client to verify url,
400             it's passed as last argument to method verifiy_client
401              
402             =head2 store_auth_code
403              
404             Function to allow you to store the generated authorization code. After the 2 common parameters,
405             The Function is passed the generated auth code, the client id, the auth code validity period in
406             seconds, the Client redirect URI, and a list of the scopes requested by the Client.
407             You should save the information to your data store, it can then be retrieved by
408             the verify_auth_code function for verification
409              
410             =head2 generate_token
411              
412             Function to generate a token. After the 2 common parameters, dsl and settings,
413             that function receives the validity period in seconds, the client id, the list of scopes,
414             the type of token and the redirect url.
415             That function should return the token that it generates, and should be unique.
416              
417             =head2 verify_auth_code
418              
419             Reference: L<http://tools.ietf.org/html/rfc6749#section-4.1.3>
420              
421             Function to verify the authorization code passed from the Client to the
422             Authorization Server. The function is passed the dsl, the settings, and then
423             the client_id, the client_secret, the authorization code, and the redirect uri.
424             The Function should verify the authorization code using the rules defined in
425             the reference RFC above, and return a list with 4 elements. The first element
426             should be a client identifier (a scalar, or reference) in the case of a valid
427             authorization code or 0 in the case of an invalid authorization code. The second
428             element should be the error message in the case of an invalid authorization
429             code. The third element should be a hash reference of scopes as requested by the
430             client in the original call for an authorization code. The fourth element should
431             be a user identifier
432              
433             =head2 store_access_token
434              
435             Function to allow you to store the generated access and refresh tokens. The
436             function is passed the dsl, the settings, and then the client identifier as
437             returned from the verify_auth_code callback, the authorization code, the access
438             token, the refresh_token, the validity period in seconds, the scope returned
439             from the verify_auth_code callback, and the old refresh token,
440              
441             Note that the passed authorization code could be undefined, in which case the
442             access token and refresh tokens were requested by the Client by the use of an
443             existing refresh token, which will be passed as the old refresh token variable.
444             In this case you should use the old refresh token to find out the previous
445             access token and revoke the previous access and refresh tokens (this is *not* a
446             hard requirement according to the OAuth spec, but i would recommend it).
447             That functions does not need to return anything.
448             You should save the information to your data store, it can then be retrieved by
449             the verify_access_token callback for verification
450              
451             =head2 verify_access_token
452              
453             Reference: L<http://tools.ietf.org/html/rfc6749#section-7>
454              
455             Function to verify the access token. The function is passed the dsl, the
456             settings and then the access token, an optional reference to a list of the
457             scopes and if the access_token is actually a refresh token. Note that the access
458             token could be the refresh token, as this method is also called when the Client
459             uses the refresh token to get a new access token (in which case the value of the
460             $is_refresh_token variable will be true).
461              
462             The function should verify the access code using the rules defined in the
463             reference RFC above, and return false if the access token is not valid otherwise
464             it should return something useful if the access token is valid - since this
465             method is called by the call to oauth_scopes you probably need to return a hash
466             of details that the access token relates to (client id, user id, etc).
467             In the event of an invalid, expired, etc, access or refresh token you should
468             return a list where the first element is 0 and the second contains the error
469             message (almost certainly 'invalid_grant' in this case)
470              
471             =head1 AUTHOR
472              
473             Pierre Vigier E<lt>pierre.vigier@gmail.comE<gt>
474              
475             =head1 CONTRIBUTORS
476              
477             Orignal plugin for mojolicious:
478              
479             Lee Johnson - C<leejo@cpan.org>
480              
481             With contributions from:
482              
483             Peter Mottram C<peter@sysnix.com>
484              
485             =head1 COPYRIGHT
486              
487             Copyright 2016- Pierre Vigier
488              
489             =head1 LICENSE
490              
491             This library is free software; you can redistribute it and/or modify
492             it under the same terms as Perl itself.
493              
494             =head1 SEE ALSO
495              
496             =cut