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.51
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   58495 use strict;
  22         44  
  22         608  
95 22     22   101 use warnings;
  22         65  
  22         589  
96 22     22   103 use base qw/ Mojolicious::Plugin /;
  22         41  
  22         2138  
97              
98 22     22   126 use Mojo::URL;
  22         42  
  22         210  
99 22     22   637 use Mojo::Parameters;
  22         40  
  22         168  
100 22     22   733 use Mojo::Util qw/ b64_decode url_unescape /;
  22         54  
  22         1266  
101 22     22   8835 use Net::OAuth2::AuthorizationServer;
  22         8979757  
  22         756  
102 22     22   213 use Carp qw/ croak /;
  22         59  
  22         47408  
103              
104             our $VERSION = '0.51';
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 1561 my ( $self,$app,$config ) = @_;
165              
166 22   100     165 my $auth_route = $config->{authorize_route} // '/oauth/authorize';
167 22   100     116 my $atoken_route = $config->{access_token_route} // '/oauth/access_token';
168              
169 22 50 66     109 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         163 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     645 ( 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         715 %{ $config },
  22         156  
185             );
186              
187             $PasswordGrant = $Server->password_grant(
188 126   100     842 ( 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         207555 %{ $config },
  21         138  
194             );
195              
196             $ImplicitGrant = $Server->implicit_grant(
197 105   100     538 ( 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         153107 %{ $config },
  21         144  
202             );
203              
204             $ClientCredentialsGrant = $Server->client_credentials_grant(
205 63   100     336 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
206             verify_client store_access_token verify_access_token
207             / ),
208 21         133732 %{ $config },
  21         140  
209             );
210              
211 21         142998 $JWTCallback = $config->{jwt_claims};
212              
213             $app->routes->get(
214 47     47   591525 $auth_route => sub { _authorization_request( @_ ) },
215 21         271 );
216              
217             $app->helper( oauth2_auth_request => sub {
218 1     1   14652 my ( $c,$args ) = @_;
219 1         3 _authorization_request( $c,$args,1 );
220 21         10701 } );
221              
222             $app->routes->post(
223 72     72   4737908 $atoken_route => sub { _access_token_request( @_ ) },
224 21         2937 );
225              
226             $app->helper(
227             oauth => sub {
228 105     105   4894262 my $c = shift;
229 105         261 my @scopes = @_;
230 105   66     360 $Grant ||= $AuthCodeGrant;
231 105         521 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         20923 my $oauth_details = $res[0];
238              
239 105 100       336 if ( ref( $oauth_details ) ) {
240 34   66     163 $oauth_details->{client_id} ||= $oauth_details->{client};
241             }
242              
243 105         321 return $oauth_details;
244             },
245 21         6268 );
246             }
247              
248             sub _authorization_request {
249 48     48   143 my ( $self,$args,$is_helper ) = @_;
250              
251 48   100     336 $args //= {};
252              
253 48   100     253 my $client_id = $args->{client_id} // $self->param( 'client_id' );
254 48   100     20331 my $uri = $args->{redirect_uri} // $self->param( 'redirect_uri' );
255 48   100     2841 my $type = $args->{response_type} // $self->param( 'response_type' );
256 48   66     2597 my $scope = $args->{scope} // $self->param( 'scope' );
257 48   66     2637 my $state = $args->{state} // $self->param( 'state' );
258 48   100     2416 my $user_id = $args->{user_id} // undef;
259              
260 48 100       229 my @scopes = $scope ? split( / /,$scope ) : ();
261              
262 48 100 100     536 if (
      66        
263             ! defined( $client_id )
264             or ! defined( $type )
265             or $type !~ /^(code|token)$/
266             ) {
267 12         126 $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         6498 return;
278             }
279              
280 36 100       144 $Grant = $type eq 'token' ? $ImplicitGrant : $AuthCodeGrant;
281              
282 36         144 my $mojo_url = Mojo::URL->new( $uri );
283 36         3314 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       5205 if ( $res ) {
292              
293 19 100       131 if ( ! $Grant->login_resource_owner(
294             mojo_controller => $self,
295             client_id => $client_id,
296             ) ) {
297 2         3363 $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         38 return;
300             } else {
301 17         425 $self->app->log->debug( "OAuth2::Server: Resource owner is logged in" );
302 17         346 $res = $Grant->confirm_by_resource_owner(
303             client_id => $client_id,
304             scopes => [ @scopes ],
305             mojo_controller => $self,
306             );
307 17 100       900 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         14 return;
311             }
312             elsif ( $res == 0 ) {
313 1         18 $self->app->log->debug( "OAuth2::Server: Resource owner denied scopes" );
314 1         12 $error = 'access_denied';
315 1   50     5 $error_description //= 'resource owner denied access';
316             }
317             }
318             }
319              
320 33 100       116 if ( $res ) {
    50          
321              
322 15 100       81 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         40 $self->app->log->debug( "OAuth2::Server: Generating auth code for $client_id" );
327 12         217 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         5080 $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         810 $mojo_url->query->append( code => $auth_code );
346              
347             } elsif ( $error ) {
348 18         70 $mojo_url->query->append( error => $error );
349 18 100       581 $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       574 $mojo_url->query->append( state => $state ) if defined( $state );
359              
360 30 50       794 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
361             }
362              
363             sub _maybe_generate_access_token {
364 3     3   9 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         1506 $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       111 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         143 $mojo_url->fragment( url_unescape( $params->to_string ) );
402 3 100       1035 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
403             }
404              
405             sub _access_token_request {
406 72     72   189 my ( $self ) = @_;
407              
408             my (
409             $client_id,$client_secret,$grant_type,$auth_code,$uri,
410             $refresh_token,$username,$password
411 72   100     204 ) = map { $self->param( $_ ) // undef } qw/
  576         49812  
412             client_id client_secret grant_type code redirect_uri
413             refresh_token username password
414             /;
415              
416 72   100     3776 $grant_type //='';
417              
418 72 100       318 _access_token_request_check_params(
419             $self,$grant_type,$username,$password,$auth_code,$uri
420             ) || return;
421              
422 51         120 my $json_response = {};
423 51         93 my $status = 400;
424              
425 51 100       208 $Grant = $grant_type eq 'password'
    100          
426             ? $PasswordGrant : $grant_type eq 'client_credentials'
427             ? $ClientCredentialsGrant : $AuthCodeGrant;
428              
429 51         242 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       204 if ( $client ) {
    50          
435              
436 23 50       109 $self->app->log->debug( "OAuth2::Server: Generating access token for @{[ ref $client ? $client->{client} : $client ]}" );
  23         348  
437              
438 23 50       368 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         355 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         8689 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       5740 $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         1652 $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     298 ? [ map { $_ } grep { $scope->{$_} } keys %{ $scope } ]
  2 100       16  
  4 100       11  
  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         84 $json_response->{error} = $error;
502 28 100       109 $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         218 $self->res->headers->header( 'Cache-Control' => 'no-store' );
516 51         2290 $self->res->headers->header( 'Pragma' => 'no-cache' );
517              
518 51         1636 $self->render(
519             status => $status,
520             json => $json_response,
521             );
522             }
523              
524             sub _access_token_request_check_params {
525 72     72   245 my ( $self,$grant_type,$username,$password,$auth_code,$uri ) = @_;
526              
527 72 100 100     836 if (
    100 66        
    100 66        
      66        
      33        
528             $grant_type eq 'password'
529             ) {
530 7 0 33     23 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         43 my ( $client_id,$client_secret ) = _client_credentials_from_header( $self );
546              
547 13 100 66     54 if ( ! $client_id || ! $client_secret ) {
548 1         6 $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         357 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         161 $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         9806 return 0;
576             }
577              
578 51         188 return 1;
579             }
580              
581             sub _client_credentials_from_header {
582 25     25   42 my ( $self ) = @_;
583              
584 25         40 my ( $client_id,$client_secret );
585              
586             # params in the header
587 25 100       58 if ( my $auth_header = $self->req->headers->header( 'Authorization' ) ) {
    50          
588 7 50       168 if ( my ( $encoded_details ) = ( split( 'Basic ',$auth_header ) )[1] ) {
589 7   100     48 my $decoded_details = b64_decode( $encoded_details // '' );
590 7         19 ( $client_id,$client_secret ) = split( ':',$decoded_details );
591 7         26 return ( $client_id,$client_secret );
592             }
593              
594             # params in the body
595             } elsif ( $client_id = $self->req->param('client_id') ) {
596 18         1232 $client_secret = $self->req->param('client_secret');
597             }
598              
599 18 50 33     616 if ( $client_id && $client_secret ) {
600 18         54 return ( $client_id,$client_secret );
601             }
602             }
603              
604             sub _verify_credentials {
605             my (
606 51     51   186 $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret,
607             $auth_code,$username,$password,$uri
608             ) = @_;
609              
610 51         119 my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description );
611              
612 51 100       223 if ( $grant_type eq 'refresh_token' ) {
    100          
    100          
613 9         42 ( $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         1980 $old_refresh_token = $refresh_token;
619              
620             } elsif ( $grant_type eq 'password' ) {
621 7         24 $scope = $self->every_param( 'scope' );
622              
623 7         405 ( $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         16 my $client_secret;
634              
635 12         24 ( $client,$client_secret ) = _client_credentials_from_header( $self );
636              
637 12         35 $scope = $self->every_param( 'scope' );
638 12         564 my $res;
639              
640 12         92 ( $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       1680 undef( $client ) if ! $res;
648              
649             } else {
650 23         183 ( $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     4158 : ( $client->{client_id} || $client->{client} );
662              
663 51         204 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