File Coverage

blib/lib/Mojolicious/Plugin/OAuth2/Server.pm
Criterion Covered Total %
statement 160 166 96.3
branch 76 90 84.4
condition 64 81 79.0
subroutine 19 19 100.0
pod 1 1 100.0
total 320 357 89.6


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::OAuth2::Server;
2              
3             =head1 NAME
4              
5             Mojolicious::Plugin::OAuth2::Server - Easier implementation of an OAuth2
6             Authorization Server / Resource Server with Mojolicious
7              
8             =for html
9             Build Status
10             Coverage Status
11              
12             =head1 VERSION
13              
14             0.49
15              
16             =head1 SYNOPSIS
17              
18             use Mojolicious::Lite;
19              
20             plugin 'OAuth2::Server' => {
21             ... # see SYNOPSIS in Net::OAuth2::AuthorizationServer::Manual
22             };
23              
24             group {
25             # /api - must be authorized
26             under '/api' => sub {
27             my ( $c ) = @_;
28              
29             return 1 if $c->oauth; # must be authorized via oauth
30              
31             $c->render( status => 401, text => 'Unauthorized' );
32             return undef;
33             };
34              
35             any '/annoy_friends' => sub { shift->render( text => "Annoyed Friends" ); };
36             any '/post_image' => sub { shift->render( text => "Posted Image" ); };
37             };
38              
39             any '/track_location' => sub {
40             my ( $c ) = @_;
41              
42             my $oauth_details = $c->oauth( 'track_location' )
43             || return $c->render( status => 401, text => 'You cannot track location' );
44              
45             $c->render( text => "Target acquired: @{[$oauth_details->{user_id}]}" );
46             };
47              
48             app->start;
49              
50             Or full fat app:
51              
52             use Mojo::Base 'Mojolicious';
53              
54             ...
55              
56             sub startup {
57             my $self = shift;
58              
59             ...
60              
61             $self->plugin( 'OAuth2::Server' => $oauth2_auth_code_grant_config );
62             }
63              
64             Then in your controller:
65              
66             sub my_route_name {
67             my ( $c ) = @_;
68            
69             if ( my $oauth_details = $c->oauth( qw/required scopes/ ) ) {
70             ... # do something, user_id, client_id, etc, available in $oauth_details
71             } else {
72             return $c->render( status => 401, text => 'Unauthorized' );
73             }
74              
75             ...
76             }
77              
78             =head1 DESCRIPTION
79              
80             This plugin implements the various OAuth2 grant types flow as described at
81             L. It is a complete implementation of
82             RFC6749, with the exception of the "Extension Grants" as the description of
83             that grant type is rather hand-wavy.
84              
85             The bulk of the functionality is implemented in the L
86             distribution, you should see that for more comprehensive documentation and
87             examples of usage.
88              
89             The examples here use the "Authorization Code Grant" flow as that is considered
90             the most secure and most complete form of OAuth2.
91              
92             =cut
93              
94 21     21   54123 use strict;
  21         42  
  21         555  
95 21     21   95 use warnings;
  21         38  
  21         526  
96 21     21   93 use base qw/ Mojolicious::Plugin /;
  21         37  
  21         1944  
97              
98 21     21   126 use Mojo::URL;
  21         38  
  21         199  
99 21     21   580 use Mojo::Parameters;
  21         36  
  21         141  
100 21     21   602 use Mojo::Util qw/ b64_decode url_unescape /;
  21         45  
  21         1129  
101 21     21   7838 use Net::OAuth2::AuthorizationServer;
  21         8407095  
  21         712  
102 21     21   194 use Carp qw/ croak /;
  21         50  
  21         42602  
103              
104             our $VERSION = '0.49';
105              
106             my ( $AuthCodeGrant,$PasswordGrant,$ImplicitGrant,$ClientCredentialsGrant,$Grant,$JWTCallback );
107              
108             =head1 METHODS
109              
110             =head2 register
111              
112             Registers the plugin with your app - note that you must pass callbacks for
113             certain functions that the plugin expects to call if you are not using the
114             plugin in its simplest form.
115              
116             $self->register($app, \%config);
117              
118             Registering the plugin will call the L
119             and create a C that can be accessed using the defined
120             C and C. The arguments passed to the
121             plugin are passed straight through to the C method in
122             the L module.
123              
124             =head2 oauth
125              
126             Checks if there is a valid Authorization: Bearer header with a valid access
127             token and if the access token has the requisite scopes. The scopes are optional:
128              
129             unless ( my $oauth_details = $c->oauth( @scopes ) ) {
130             return $c->render( status => 401, text => 'Unauthorized' );
131             }
132              
133             This calls the L
134             module (C method) to validate the access/refresh token.
135              
136             =head2 oauth2_auth_request
137              
138             This is a helper to allow you get get the redirect URI instead of directing
139             a user to the authorize_route - it requires the details of the client:
140              
141             my $redirect_uri = $c->oauth2_auth_request({
142             client_id => $client_id,
143             redirect_uri => 'https://foo',
144             response_type => 'token',
145             scope => 'list,of,scopes',
146             state => 'foo=bar&baz=boz',
147             });
148              
149             if ( $redirect_uri ) {
150             # do something with $redirect_uri
151             } else {
152             # something didn't work, e.g. bad client, scopes, etc
153             }
154              
155             You can use this helper instead of directing a user to the authorize_route if
156             you need to do something more involved with the redirect_uri rather than
157             having the plugin direct to the user to the resulting redirect uri
158              
159             =cut
160              
161             my $warned_dep = 0;
162              
163             sub register {
164 21     21 1 1371 my ( $self,$app,$config ) = @_;
165              
166 21   100     193 my $auth_route = $config->{authorize_route} // '/oauth/authorize';
167 21   100     103 my $atoken_route = $config->{access_token_route} // '/oauth/access_token';
168              
169 21 50 66     106 if ( $config->{users} && ! $config->{jwt_secret} ) {
170 0         0 croak "You MUST provide a jwt_secret to use the password grant (users supplied)";
171             }
172              
173 21         154 my $Server = Net::OAuth2::AuthorizationServer->new;
174              
175             # note that access_tokens and refresh_tokens will not be shared between
176             # the various grant type objects, so if you need to support
177             # both then you *must* either supply a jwt_secret or supply callbacks
178             $AuthCodeGrant = $Server->auth_code_grant(
179 147   100     542 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
180             verify_client store_auth_code verify_auth_code
181             store_access_token verify_access_token
182             login_resource_owner confirm_by_resource_owner
183             / ),
184 21         691 %{ $config },
  21         143  
185             );
186              
187             $PasswordGrant = $Server->password_grant(
188 120   100     767 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
189             verify_client verify_user_password
190             store_access_token verify_access_token
191             login_resource_owner confirm_by_resource_owner
192             / ),
193 20         191516 %{ $config },
  20         119  
194             );
195              
196             $ImplicitGrant = $Server->implicit_grant(
197 100   100     471 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
198             verify_client store_access_token verify_access_token
199             login_resource_owner confirm_by_resource_owner
200             / ),
201 20         140260 %{ $config },
  20         117  
202             );
203              
204             $ClientCredentialsGrant = $Server->client_credentials_grant(
205 60   100     303 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
206             verify_client store_access_token verify_access_token
207             / ),
208 20         122670 %{ $config },
  20         117  
209             );
210              
211 20         131682 $JWTCallback = $config->{jwt_claims};
212              
213             $app->routes->get(
214 47     47   557422 $auth_route => sub { _authorization_request( @_ ) },
215 20         226 );
216              
217             $app->helper( oauth2_auth_request => sub {
218 1     1   14599 my ( $c,$args ) = @_;
219 1         4 _authorization_request( $c,$args,1 );
220 20         9895 } );
221              
222             $app->routes->post(
223 67     67   4621481 $atoken_route => sub { _access_token_request( @_ ) },
224 20         2741 );
225              
226             $app->helper(
227             oauth => sub {
228 100     100   4755274 my $c = shift;
229 100         225 my @scopes = @_;
230 100   66     279 $Grant ||= $AuthCodeGrant;
231 100         435 my @res = $Grant->verify_token_and_scope(
232             scopes => [ @scopes ],
233             auth_header => $c->req->headers->header( 'Authorization' ),
234             mojo_controller => $c,
235             );
236              
237 100         17190 my $oauth_details = $res[0];
238              
239 100 100       317 if ( ref( $oauth_details ) ) {
240 32   66     124 $oauth_details->{client_id} ||= $oauth_details->{client};
241             }
242              
243 100         285 return $oauth_details;
244             },
245 20         5807 );
246             }
247              
248             sub _authorization_request {
249 48     48   139 my ( $self,$args,$is_helper ) = @_;
250              
251 48   100     330 $args //= {};
252              
253 48   100     247 my $client_id = $args->{client_id} // $self->param( 'client_id' );
254 48   100     19909 my $uri = $args->{redirect_uri} // $self->param( 'redirect_uri' );
255 48   100     2747 my $type = $args->{response_type} // $self->param( 'response_type' );
256 48   66     2517 my $scope = $args->{scope} // $self->param( 'scope' );
257 48   66     2628 my $state = $args->{state} // $self->param( 'state' );
258 48   100     2389 my $user_id = $args->{user_id} // undef;
259              
260 48 100       230 my @scopes = $scope ? split( / /,$scope ) : ();
261              
262 48 100 100     506 if (
      66        
263             ! defined( $client_id )
264             or ! defined( $type )
265             or $type !~ /^(code|token)$/
266             ) {
267 12         87 $self->render(
268             status => 400,
269             json => {
270             error => 'invalid_request',
271             error_description => 'the request was missing one of: client_id, '
272             . 'response_type;'
273             . 'or response_type did not equal "code" or "token"',
274             error_uri => '',
275             }
276             );
277 12         5909 return;
278             }
279              
280 36 100       145 $Grant = $type eq 'token' ? $ImplicitGrant : $AuthCodeGrant;
281              
282 36         140 my $mojo_url = Mojo::URL->new( $uri );
283 36         2940 my ( $res,$error,$error_description ) = $Grant->verify_client(
284             client_id => $client_id,
285             redirect_uri => $uri,
286             scopes => [ @scopes ],
287             mojo_controller => $self,
288             response_type => $type,
289             );
290              
291 36 100       4026 if ( $res ) {
292              
293 19 100       107 if ( ! $Grant->login_resource_owner(
294             mojo_controller => $self,
295             client_id => $client_id,
296             ) ) {
297 2         3248 $self->app->log->debug( "OAuth2::Server: Resource owner not logged in" );
298             # call to $resource_owner_logged_in method should have called redirect_to
299 2         36 return;
300             } else {
301 17         436 $self->app->log->debug( "OAuth2::Server: Resource owner is logged in" );
302 17         331 $res = $Grant->confirm_by_resource_owner(
303             client_id => $client_id,
304             scopes => [ @scopes ],
305             mojo_controller => $self,
306             );
307 17 100       996 if ( ! defined $res ) {
    100          
308 1         6 $self->app->log->debug( "OAuth2::Server: Resource owner to confirm scopes" );
309             # call to $resource_owner_confirms method should have called redirect_to
310 1         14 return;
311             }
312             elsif ( $res == 0 ) {
313 1         4 $self->app->log->debug( "OAuth2::Server: Resource owner denied scopes" );
314 1         11 $error = 'access_denied';
315 1   50     10 $error_description //= 'resource owner denied access';
316             }
317             }
318             }
319              
320 33 100       112 if ( $res ) {
    50          
321              
322 15 100       79 return _maybe_generate_access_token(
323             $self,$mojo_url,$client_id,[ @scopes ],$state,$is_helper,$user_id
324             ) if $type eq 'token'; # implicit grant
325              
326 12         55 $self->app->log->debug( "OAuth2::Server: Generating auth code for $client_id" );
327 12         200 my $auth_code = $Grant->token(
328             client_id => $client_id,
329             scopes => [ @scopes ],
330             type => 'auth',
331             redirect_uri => $uri,
332             jwt_claims_cb => $JWTCallback,
333             user_id => $user_id,
334             );
335              
336 12         5021 $Grant->store_auth_code(
337             auth_code => $auth_code,
338             client_id => $client_id,
339             expires_in => $Grant->auth_code_ttl,
340             redirect_uri => $uri,
341             scopes => [ @scopes ],
342             mojo_controller => $self,
343             );
344              
345 12         873 $mojo_url->query->append( code => $auth_code );
346              
347             } elsif ( $error ) {
348 18         54 $mojo_url->query->append( error => $error );
349 18 100       563 $mojo_url->query->append( error_description => $error_description ) if $error_description;
350             } else {
351             # callback has not returned anything, assume server error
352 0         0 $mojo_url->query->append(
353             error => 'server_error',
354             error_description => 'call to verify_client returned unexpected value',
355             );
356             }
357              
358 30 100       557 $mojo_url->query->append( state => $state ) if defined( $state );
359              
360 30 50       748 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
361             }
362              
363             sub _maybe_generate_access_token {
364 3     3   10 my ( $self,$mojo_url,$client,$scope,$state,$is_helper,$user_id ) = @_;
365              
366 3 50       22 my $access_token_ttl = $Grant->can('get_access_token_ttl')
367             ? $Grant->get_access_token_ttl(
368             scopes => $scope,
369             client_id => $client,
370             )
371             : $Grant->access_token_ttl;
372              
373 3         38 my $access_token = $Grant->token(
374             client_id => $client,
375             scopes => $scope,
376             type => 'access',
377             jwt_claims_cb => $JWTCallback,
378             user_id => $user_id,
379             );
380              
381 3         1390 $Grant->store_access_token(
382             client_id => $client,
383             access_token => $access_token,
384             expires_in => $access_token_ttl,
385             scopes => $scope,
386             mojo_controller => $self,
387             );
388              
389             # http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
390             # &state=xyz&token_type=example&expires_in=3600
391 3 100       104 my $params = Mojo::Parameters->new(
392             access_token => $access_token,
393             token_type => 'bearer',
394             expires_in => $access_token_ttl,
395             ( $state
396             ? ( state => $state )
397             : (),
398             )
399             );
400              
401 3         123 $mojo_url->fragment( url_unescape( $params->to_string ) );
402 3 100       977 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
403             }
404              
405             sub _access_token_request {
406 67     67   161 my ( $self ) = @_;
407              
408             my (
409             $client_id,$client_secret,$grant_type,$auth_code,$uri,
410             $refresh_token,$username,$password
411 67   100     162 ) = map { $self->param( $_ ) // undef } qw/
  536         45921  
412             client_id client_secret grant_type code redirect_uri
413             refresh_token username password
414             /;
415              
416 67   100     3331 $grant_type //='';
417              
418 67 100       256 _access_token_request_check_params(
419             $self,$grant_type,$username,$password,$auth_code,$uri
420             ) || return;
421              
422 42         103 my $json_response = {};
423 42         83 my $status = 400;
424              
425 42 100       167 $Grant = $grant_type eq 'password'
    100          
426             ? $PasswordGrant : $grant_type eq 'client_credentials'
427             ? $ClientCredentialsGrant : $AuthCodeGrant;
428              
429 42         163 my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description ) = _verify_credentials(
430             $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret,
431             $auth_code,$username,$password,$uri
432             );
433              
434 42 100       145 if ( $client ) {
    50          
435              
436 22 50       180 $self->app->log->debug( "OAuth2::Server: Generating access token for @{[ ref $client ? $client->{client} : $client ]}" );
  22         332  
437              
438 22 50       337 my $access_token_ttl = $Grant->can('get_access_token_ttl')
439             ? $Grant->get_access_token_ttl(
440             scopes => $scope,
441             client_id => $client_id,
442             )
443             : $Grant->access_token_ttl;
444              
445 22         332 my $access_token = $Grant->token(
446             client_id => $client,
447             scopes => $scope,
448             type => 'access',
449             user_id => $user_id,
450             jwt_claims_cb => $JWTCallback,
451             );
452              
453 22         7313 my $refresh_token = $Grant->token(
454             client_id => $client,
455             scopes => $scope,
456             type => 'refresh',
457             user_id => $user_id,
458             jwt_claims_cb => $JWTCallback,
459             );
460              
461 22 100       5444 $Grant->store_access_token(
    100          
462             client_id => $client,
463             ( $grant_type ne 'password' ? ( auth_code => $auth_code ) : () ),
464             access_token => $access_token,
465             expires_in => $access_token_ttl,
466             scopes => $scope,
467             ( $grant_type eq 'client_credentials'
468             ? ()
469             : (
470             refresh_token => $refresh_token,
471             old_refresh_token => $old_refresh_token,
472             )
473             ),
474             mojo_controller => $self,
475             );
476              
477 22         1555 $status = 200;
478             $json_response = {
479             # RFC6749 section 5.1 says that Access Token Response should include
480             # the authorized scope when it does not match the requested scopes:
481             #
482             # OPTIONAL, if identical to the scope requested by the client;
483             # otherwise, REQUIRED. The scope of the access token as
484             # described by Section 3.3.
485             ( ! $old_refresh_token && $scope
486             ? ( scopes => ref( $scope ) eq 'HASH'
487 22 100 66     269 ? [ map { $_ } grep { $scope->{$_} } keys %{ $scope } ]
  2 100       17  
  4 100       10  
  2         8  
488             : $scope )
489             : ()),
490              
491             access_token => $access_token,
492             token_type => 'Bearer',
493             expires_in => $access_token_ttl,
494             ( $grant_type eq 'client_credentials'
495             ? ()
496             : ( refresh_token => $refresh_token ),
497             )
498             };
499              
500             } elsif ( $error ) {
501 20         52 $json_response->{error} = $error;
502 20 100       74 $json_response->{error_description} = $error_description if $error_description;
503             } else {
504             # callback has not returned anything, assume server error
505 0 0       0 my $method = $grant_type eq 'password'
    0          
506             ? 'verify_user_password' : $grant_type eq 'client_credentials'
507             ? 'verify_client' : 'verify_auth_code';
508              
509 0         0 $json_response = {
510             error => 'server_error',
511             error_description => "call to $method returned unexpected value",
512             };
513             }
514              
515 42         180 $self->res->headers->header( 'Cache-Control' => 'no-store' );
516 42         1686 $self->res->headers->header( 'Pragma' => 'no-cache' );
517              
518 42         1315 $self->render(
519             status => $status,
520             json => $json_response,
521             );
522             }
523              
524             sub _access_token_request_check_params {
525 67     67   182 my ( $self,$grant_type,$username,$password,$auth_code,$uri ) = @_;
526              
527 67 100 100     721 if (
    100 66        
    100 66        
      66        
      33        
528             $grant_type eq 'password'
529             ) {
530 7 0 33     19 if ( ! $username && ! $password ) {
531 0         0 $self->render(
532             status => 400,
533             json => {
534             error => 'invalid_request',
535             error_description => 'the request was missing one of: '
536             . 'client_id, client_secret, username, password',
537             error_uri => '',
538             }
539             );
540 0         0 return 0;
541             }
542             } elsif (
543             $grant_type eq 'client_credentials'
544             ) {
545 10         25 my ( $client_id,$client_secret ) = _client_credentials_from_header( $self );
546              
547 10 100 66     159 if ( ! $client_id || ! $client_secret ) {
548 7         35 $self->render(
549             status => 400,
550             json => {
551             error => 'invalid_request',
552             error_description => 'the request was missing an Authorization: Basic'
553             . ' header or it was missing the encoded client_id:client_secret data',
554             error_uri => '',
555             }
556             );
557 7         2510 return 0;
558             }
559             } elsif (
560             ( $grant_type ne 'authorization_code' and $grant_type ne 'refresh_token' )
561             or ( $grant_type eq 'authorization_code' and ! defined( $auth_code ) )
562             or ( $grant_type eq 'authorization_code' and ! defined( $uri ) )
563             ) {
564 18         124 $self->render(
565             status => 400,
566             json => {
567             error => 'invalid_request',
568             error_description => 'the request was missing one of: grant_type, '
569             . 'client_id, client_secret, code, redirect_uri;'
570             . 'or grant_type did not equal "authorization_code" '
571             . 'or "refresh_token"',
572             error_uri => '',
573             }
574             );
575 18         7981 return 0;
576             }
577              
578 42         134 return 1;
579             }
580              
581             sub _client_credentials_from_header {
582 13     13   22 my ( $self ) = @_;
583              
584 13 100       31 if ( my $auth_header = $self->req->headers->header( 'Authorization' ) ) {
585 7 50       173 if ( my ( $encoded_details ) = ( split( 'Basic ',$auth_header ) )[1] ) {
586 7   100     28 my $decoded_details = b64_decode( $encoded_details // '' );
587 7         23 my ( $client_id,$client_secret ) = split( ':',$decoded_details );
588 7         24 return ( $client_id,$client_secret );
589             }
590             }
591             }
592              
593             sub _verify_credentials {
594             my (
595 42     42   159 $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret,
596             $auth_code,$username,$password,$uri
597             ) = @_;
598              
599 42         93 my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description );
600              
601 42 100       217 if ( $grant_type eq 'refresh_token' ) {
    100          
    100          
602 9         40 ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_token_and_scope(
603             refresh_token => $refresh_token,
604             auth_header => $self->req->headers->header( 'Authorization' ),
605             mojo_controller => $self,
606             );
607 9         1788 $old_refresh_token = $refresh_token;
608              
609             } elsif ( $grant_type eq 'password' ) {
610 7         15 $scope = $self->every_param( 'scope' );
611              
612 7         355 ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_user_password(
613             client_id => $client_id,
614             client_secret => $client_secret,
615             username => $username,
616             password => $password,
617             mojo_controller => $self,
618             scopes => $scope,
619             );
620             } elsif ( $grant_type eq 'client_credentials' ) {
621              
622 3         6 my $client_secret;
623              
624 3         9 ( $client,$client_secret ) = _client_credentials_from_header( $self );
625              
626 3         13 $scope = $self->every_param( 'scope' );
627 3         143 my $res;
628              
629 3         45 ( $res,$error,$error_description ) = $Grant->verify_client(
630             client_id => $client,
631             client_secret => $client_secret,
632             mojo_controller => $self,
633             scopes => $scope,
634             );
635              
636 3 100       545 undef( $client ) if ! $res;
637              
638             } else {
639 23         171 ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_auth_code(
640             client_id => $client_id,
641             client_secret => $client_secret,
642             auth_code => $auth_code,
643             redirect_uri => $uri,
644             mojo_controller => $self,
645             );
646             }
647              
648             $client = ! ref $client
649             ? $client
650 42 100 66     3920 : ( $client->{client_id} || $client->{client} );
651              
652 42         169 return ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description );
653             }
654              
655             =head1 SEE ALSO
656              
657             L - The dist that handles the bulk of the
658             functionality used by this plugin
659              
660             =head1 AUTHOR & CONTRIBUTORS
661              
662             Lee Johnson - C
663              
664             With contributions from:
665              
666             Nick Logan C
667              
668             Pierre VIGIER C
669              
670             Renee C
671              
672             =head1 LICENSE
673              
674             This library is free software; you can redistribute it and/or modify it under
675             the same terms as Perl itself. If you would like to contribute documentation
676             or file a bug report then please raise an issue / pull request:
677              
678             https://github.com/Humanstate/mojolicious-plugin-oauth2-server
679              
680             =cut
681              
682             1;
683              
684             # vim: ts=2:sw=2:et