File Coverage

blib/lib/Mojolicious/Plugin/OAuth2/Server.pm
Criterion Covered Total %
statement 164 170 96.4
branch 78 94 82.9
condition 65 84 77.3
subroutine 19 19 100.0
pod 1 1 100.0
total 327 368 88.8


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.50
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 22     22   53784 use strict;
  22         47  
  22         570  
95 22     22   104 use warnings;
  22         36  
  22         529  
96 22     22   93 use base qw/ Mojolicious::Plugin /;
  22         42  
  22         1893  
97              
98 22     22   131 use Mojo::URL;
  22         48  
  22         214  
99 22     22   600 use Mojo::Parameters;
  22         44  
  22         163  
100 22     22   643 use Mojo::Util qw/ b64_decode url_unescape /;
  22         44  
  22         1123  
101 22     22   8180 use Net::OAuth2::AuthorizationServer;
  22         8753221  
  22         727  
102 22     22   182 use Carp qw/ croak /;
  22         48  
  22         46102  
103              
104             our $VERSION = '0.50';
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 22     22 1 1414 my ( $self,$app,$config ) = @_;
165              
166 22   100     163 my $auth_route = $config->{authorize_route} // '/oauth/authorize';
167 22   100     114 my $atoken_route = $config->{access_token_route} // '/oauth/access_token';
168              
169 22 50 66     101 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 22         140 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 154   100     563 ( 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 22         655 %{ $config },
  22         152  
185             );
186              
187             $PasswordGrant = $Server->password_grant(
188 126   100     794 ( 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 21         200804 %{ $config },
  21         123  
194             );
195              
196             $ImplicitGrant = $Server->implicit_grant(
197 105   100     493 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
198             verify_client store_access_token verify_access_token
199             login_resource_owner confirm_by_resource_owner
200             / ),
201 21         147367 %{ $config },
  21         126  
202             );
203              
204             $ClientCredentialsGrant = $Server->client_credentials_grant(
205 63   100     306 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
206             verify_client store_access_token verify_access_token
207             / ),
208 21         128132 %{ $config },
  21         126  
209             );
210              
211 21         136926 $JWTCallback = $config->{jwt_claims};
212              
213             $app->routes->get(
214 47     47   531465 $auth_route => sub { _authorization_request( @_ ) },
215 21         243 );
216              
217             $app->helper( oauth2_auth_request => sub {
218 1     1   14994 my ( $c,$args ) = @_;
219 1         5 _authorization_request( $c,$args,1 );
220 21         10572 } );
221              
222             $app->routes->post(
223 72     72   4657880 $atoken_route => sub { _access_token_request( @_ ) },
224 21         2836 );
225              
226             $app->helper(
227             oauth => sub {
228 105     105   4769296 my $c = shift;
229 105         220 my @scopes = @_;
230 105   66     271 $Grant ||= $AuthCodeGrant;
231 105         402 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 105         17641 my $oauth_details = $res[0];
238              
239 105 100       285 if ( ref( $oauth_details ) ) {
240 34   66     126 $oauth_details->{client_id} ||= $oauth_details->{client};
241             }
242              
243 105         282 return $oauth_details;
244             },
245 21         6130 );
246             }
247              
248             sub _authorization_request {
249 48     48   116 my ( $self,$args,$is_helper ) = @_;
250              
251 48   100     355 $args //= {};
252              
253 48   100     243 my $client_id = $args->{client_id} // $self->param( 'client_id' );
254 48   100     18811 my $uri = $args->{redirect_uri} // $self->param( 'redirect_uri' );
255 48   100     2768 my $type = $args->{response_type} // $self->param( 'response_type' );
256 48   66     2400 my $scope = $args->{scope} // $self->param( 'scope' );
257 48   66     2594 my $state = $args->{state} // $self->param( 'state' );
258 48   100     2395 my $user_id = $args->{user_id} // undef;
259              
260 48 100       199 my @scopes = $scope ? split( / /,$scope ) : ();
261              
262 48 100 100     496 if (
      66        
263             ! defined( $client_id )
264             or ! defined( $type )
265             or $type !~ /^(code|token)$/
266             ) {
267 12         86 $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         5898 return;
278             }
279              
280 36 100       148 $Grant = $type eq 'token' ? $ImplicitGrant : $AuthCodeGrant;
281              
282 36         136 my $mojo_url = Mojo::URL->new( $uri );
283 36         2901 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       3621 if ( $res ) {
292              
293 19 100       91 if ( ! $Grant->login_resource_owner(
294             mojo_controller => $self,
295             client_id => $client_id,
296             ) ) {
297 2         3060 $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         37 return;
300             } else {
301 17         420 $self->app->log->debug( "OAuth2::Server: Resource owner is logged in" );
302 17         323 $res = $Grant->confirm_by_resource_owner(
303             client_id => $client_id,
304             scopes => [ @scopes ],
305             mojo_controller => $self,
306             );
307 17 100       940 if ( ! defined $res ) {
    100          
308 1         4 $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         16 return;
311             }
312             elsif ( $res == 0 ) {
313 1         5 $self->app->log->debug( "OAuth2::Server: Resource owner denied scopes" );
314 1         11 $error = 'access_denied';
315 1   50     7 $error_description //= 'resource owner denied access';
316             }
317             }
318             }
319              
320 33 100       119 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         44 $self->app->log->debug( "OAuth2::Server: Generating auth code for $client_id" );
327 12         253 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         5413 $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         799 $mojo_url->query->append( code => $auth_code );
346              
347             } elsif ( $error ) {
348 18         57 $mojo_url->query->append( error => $error );
349 18 100       555 $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       550 $mojo_url->query->append( state => $state ) if defined( $state );
359              
360 30 50       729 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       25 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         40 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         1438 $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       117 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         140 $mojo_url->fragment( url_unescape( $params->to_string ) );
402 3 100       991 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
403             }
404              
405             sub _access_token_request {
406 72     72   168 my ( $self ) = @_;
407              
408             my (
409             $client_id,$client_secret,$grant_type,$auth_code,$uri,
410             $refresh_token,$username,$password
411 72   100     173 ) = map { $self->param( $_ ) // undef } qw/
  576         47309  
412             client_id client_secret grant_type code redirect_uri
413             refresh_token username password
414             /;
415              
416 72   100     3568 $grant_type //='';
417              
418 72 100       253 _access_token_request_check_params(
419             $self,$grant_type,$username,$password,$auth_code,$uri
420             ) || return;
421              
422 51         111 my $json_response = {};
423 51         94 my $status = 400;
424              
425 51 100       179 $Grant = $grant_type eq 'password'
    100          
426             ? $PasswordGrant : $grant_type eq 'client_credentials'
427             ? $ClientCredentialsGrant : $AuthCodeGrant;
428              
429 51         164 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 51 100       196 if ( $client ) {
    50          
435              
436 23 50       115 $self->app->log->debug( "OAuth2::Server: Generating access token for @{[ ref $client ? $client->{client} : $client ]}" );
  23         336  
437              
438 23 50       364 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 23         344 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 23         8088 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 23 100       5841 $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 23         1551 $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 23 100 66     295 ? [ map { $_ } grep { $scope->{$_} } keys %{ $scope } ]
  2 100       17  
  4 100       9  
  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 28         77 $json_response->{error} = $error;
502 28 100       106 $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 51         195 $self->res->headers->header( 'Cache-Control' => 'no-store' );
516 51         1966 $self->res->headers->header( 'Pragma' => 'no-cache' );
517              
518 51         1524 $self->render(
519             status => $status,
520             json => $json_response,
521             );
522             }
523              
524             sub _access_token_request_check_params {
525 72     72   206 my ( $self,$grant_type,$username,$password,$auth_code,$uri ) = @_;
526              
527 72 100 100     709 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 13         30 my ( $client_id,$client_secret ) = _client_credentials_from_header( $self );
546              
547 13 100 66     51 if ( ! $client_id || ! $client_secret ) {
548 1         8 $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 1         361 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 20         148 $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 20         8965 return 0;
576             }
577              
578 51         166 return 1;
579             }
580              
581             sub _client_credentials_from_header {
582 25     25   37 my ( $self ) = @_;
583              
584 25         42 my ( $client_id,$client_secret );
585              
586             # params in the header
587 25 100       54 if ( my $auth_header = $self->req->headers->header( 'Authorization' ) ) {
    50          
588 7 50       160 if ( my ( $encoded_details ) = ( split( 'Basic ',$auth_header ) )[1] ) {
589 7   100     41 my $decoded_details = b64_decode( $encoded_details // '' );
590 7         19 ( $client_id,$client_secret ) = split( ':',$decoded_details );
591 7         22 return ( $client_id,$client_secret );
592             }
593              
594             # params in the body
595             } elsif ( $client_id = $self->req->param('client_id') ) {
596 18         950 $client_secret = $self->req->param('client_secret');
597             }
598              
599 18 50 33     584 if ( $client_id && $client_secret ) {
600 18         53 return ( $client_id,$client_secret );
601             }
602             }
603              
604             sub _verify_credentials {
605             my (
606 51     51   162 $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret,
607             $auth_code,$username,$password,$uri
608             ) = @_;
609              
610 51         113 my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description );
611              
612 51 100       191 if ( $grant_type eq 'refresh_token' ) {
    100          
    100          
613 9         39 ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_token_and_scope(
614             refresh_token => $refresh_token,
615             auth_header => $self->req->headers->header( 'Authorization' ),
616             mojo_controller => $self,
617             );
618 9         1685 $old_refresh_token = $refresh_token;
619              
620             } elsif ( $grant_type eq 'password' ) {
621 7         19 $scope = $self->every_param( 'scope' );
622              
623 7         362 ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_user_password(
624             client_id => $client_id,
625             client_secret => $client_secret,
626             username => $username,
627             password => $password,
628             mojo_controller => $self,
629             scopes => $scope,
630             );
631             } elsif ( $grant_type eq 'client_credentials' ) {
632              
633 12         17 my $client_secret;
634              
635 12         21 ( $client,$client_secret ) = _client_credentials_from_header( $self );
636              
637 12         30 $scope = $self->every_param( 'scope' );
638 12         564 my $res;
639              
640 12         70 ( $res,$error,$error_description ) = $Grant->verify_client(
641             client_id => $client,
642             client_secret => $client_secret,
643             mojo_controller => $self,
644             scopes => $scope,
645             );
646              
647 12 100       1340 undef( $client ) if ! $res;
648              
649             } else {
650 23         141 ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_auth_code(
651             client_id => $client_id,
652             client_secret => $client_secret,
653             auth_code => $auth_code,
654             redirect_uri => $uri,
655             mojo_controller => $self,
656             );
657             }
658              
659             $client = ! ref $client
660             ? $client
661 51 100 66     3870 : ( $client->{client_id} || $client->{client} );
662              
663 51         243 return ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description );
664             }
665              
666             =head1 SEE ALSO
667              
668             L - The dist that handles the bulk of the
669             functionality used by this plugin
670              
671             =head1 AUTHOR & CONTRIBUTORS
672              
673             Lee Johnson - C
674              
675             With contributions from:
676              
677             Nick Logan C
678              
679             Pierre VIGIER C
680              
681             Renee C
682              
683             =head1 LICENSE
684              
685             This library is free software; you can redistribute it and/or modify it under
686             the same terms as Perl itself. If you would like to contribute documentation
687             or file a bug report then please raise an issue / pull request:
688              
689             https://github.com/Humanstate/mojolicious-plugin-oauth2-server
690              
691             =cut
692              
693             1;
694              
695             # vim: ts=2:sw=2:et