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.52
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   25927978 use strict;
  22         58  
  22         968  
95 22     22   141 use warnings;
  22         51  
  22         1532  
96 22     22   140 use base qw/ Mojolicious::Plugin /;
  22         47  
  22         3585  
97              
98 22     22   167 use Mojo::URL;
  22         51  
  22         320  
99 22     22   861 use Mojo::Parameters;
  22         75  
  22         228  
100 22     22   933 use Mojo::Util qw/ b64_decode url_unescape /;
  22         53  
  22         1932  
101 22     22   11313 use Net::OAuth2::AuthorizationServer;
  22         9614526  
  22         1010  
102 22     22   277 use Carp qw/ croak /;
  22         91  
  22         64279  
103              
104             our $VERSION = '0.52';
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 2187 my ( $self,$app,$config ) = @_;
165              
166 22   100     215 my $auth_route = $config->{authorize_route} // '/oauth/authorize';
167 22   100     158 my $atoken_route = $config->{access_token_route} // '/oauth/access_token';
168              
169 22 50 66     118 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         225 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     655 ( 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         1088 %{ $config },
  22         194  
185             );
186              
187             $PasswordGrant = $Server->password_grant(
188 126   100     684 ( 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         250479 %{ $config },
  21         181  
194             );
195              
196             $ImplicitGrant = $Server->implicit_grant(
197 105   100     564 ( 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         181654 %{ $config },
  21         181  
202             );
203              
204             $ClientCredentialsGrant = $Server->client_credentials_grant(
205 63   100     463 ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/
206             verify_client store_access_token verify_access_token
207             / ),
208 21         165079 %{ $config },
  21         206  
209             );
210              
211 21         182941 $JWTCallback = $config->{jwt_claims};
212              
213             $app->routes->get(
214 47     47   828335 $auth_route => sub { _authorization_request( @_ ) },
215 21         371 );
216              
217             $app->helper( oauth2_auth_request => sub {
218 1     1   24703 my ( $c,$args ) = @_;
219 1         5 _authorization_request( $c,$args,1 );
220 21         13851 } );
221              
222             $app->routes->post(
223 72     72   5113092 $atoken_route => sub { _access_token_request( @_ ) },
224 21         3761 );
225              
226             $app->helper(
227             oauth => sub {
228 105     105   5330735 my $c = shift;
229 105         319 my @scopes = @_;
230 105   66     495 $Grant ||= $AuthCodeGrant;
231 105         606 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         31548 my $oauth_details = $res[0];
238              
239 105 100       418 if ( ref( $oauth_details ) ) {
240 34   66     194 $oauth_details->{client_id} ||= $oauth_details->{client};
241             }
242              
243 105         473 return $oauth_details;
244             },
245 21         8766 );
246             }
247              
248             sub _authorization_request {
249 48     48   172 my ( $self,$args,$is_helper ) = @_;
250              
251 48   100     408 $args //= {};
252              
253 48   100     374 my $client_id = $args->{client_id} // $self->param( 'client_id' );
254 48   100     26693 my $uri = $args->{redirect_uri} // $self->param( 'redirect_uri' );
255 48   100     3788 my $type = $args->{response_type} // $self->param( 'response_type' );
256 48   66     3534 my $scope = $args->{scope} // $self->param( 'scope' );
257 48   66     3479 my $state = $args->{state} // $self->param( 'state' );
258 48   100     3230 my $user_id = $args->{user_id} // undef;
259              
260 48 100       303 my @scopes = $scope ? split( / /,$scope ) : ();
261              
262 48 100 100     710 if (
      66        
263             ! defined( $client_id )
264             or ! defined( $type )
265             or $type !~ /^(code|token)$/
266             ) {
267 12         166 $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         7255 return;
278             }
279              
280 36 100       194 $Grant = $type eq 'token' ? $ImplicitGrant : $AuthCodeGrant;
281              
282 36         264 my $mojo_url = Mojo::URL->new( $uri );
283 36         4153 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       9554 if ( $res ) {
292              
293 19 100       122 if ( ! $Grant->login_resource_owner(
294             mojo_controller => $self,
295             client_id => $client_id,
296             ) ) {
297 2         3254 $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         601 $self->app->log->debug( "OAuth2::Server: Resource owner is logged in" );
302 17         469 $res = $Grant->confirm_by_resource_owner(
303             client_id => $client_id,
304             scopes => [ @scopes ],
305             mojo_controller => $self,
306             );
307 17 100       1736 if ( ! defined $res ) {
    100          
308 1         7 $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         59 return;
311             }
312             elsif ( $res == 0 ) {
313 1         6 $self->app->log->debug( "OAuth2::Server: Resource owner denied scopes" );
314 1         16 $error = 'access_denied';
315 1   50     10 $error_description //= 'resource owner denied access';
316             }
317             }
318             }
319              
320 33 100       144 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         68 $self->app->log->debug( "OAuth2::Server: Generating auth code for $client_id" );
327 12         278 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         6714 $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         1158 $mojo_url->query->append( code => $auth_code );
346              
347             } elsif ( $error ) {
348 18         88 $mojo_url->query->append( error => $error );
349 18 100       836 $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       705 $mojo_url->query->append( state => $state ) if defined( $state );
359              
360 30 50       1000 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
361             }
362              
363             sub _maybe_generate_access_token {
364 3     3   13 my ( $self,$mojo_url,$client,$scope,$state,$is_helper,$user_id ) = @_;
365              
366 3 50       28 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         43 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         1638 $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       157 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         182 $mojo_url->fragment( url_unescape( $params->to_string ) );
402 3 100       1147 return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url );
403             }
404              
405             sub _access_token_request {
406 72     72   233 my ( $self ) = @_;
407              
408             my (
409             $client_id,$client_secret,$grant_type,$auth_code,$uri,
410             $refresh_token,$username,$password
411 72   100     340 ) = map { $self->param( $_ ) // undef } qw/
  576         71768  
412             client_id client_secret grant_type code redirect_uri
413             refresh_token username password
414             /;
415              
416 72   100     5574 $grant_type //='';
417              
418 72 100       395 _access_token_request_check_params(
419             $self,$grant_type,$username,$password,$auth_code,$uri
420             ) || return;
421              
422 51         170 my $json_response = {};
423 51         117 my $status = 400;
424              
425 51 100       314 $Grant = $grant_type eq 'password'
    100          
426             ? $PasswordGrant : $grant_type eq 'client_credentials'
427             ? $ClientCredentialsGrant : $AuthCodeGrant;
428              
429 51         259 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       227 if ( $client ) {
    50          
435              
436 23 50       141 $self->app->log->debug( "OAuth2::Server: Generating access token for @{[ ref $client ? $client->{client} : $client ]}" );
  23         526  
437              
438 23 50       532 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         509 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         11121 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       8321 $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         2292 $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     412 ? [ map { $_ } grep { $scope->{$_} } keys %{ $scope } ]
  2 100       30  
  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         127 $json_response->{error} = $error;
502 28 100       131 $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         349 $self->res->headers->header( 'Cache-Control' => 'no-store' );
516 51         3208 $self->res->headers->header( 'Pragma' => 'no-cache' );
517              
518 51         2355 $self->render(
519             status => $status,
520             json => $json_response,
521             );
522             }
523              
524             sub _access_token_request_check_params {
525 72     72   368 my ( $self,$grant_type,$username,$password,$auth_code,$uri ) = @_;
526              
527 72 100 100     1189 if (
    100 66        
    100 66        
      66        
      33        
528             $grant_type eq 'password'
529             ) {
530 7 0 33     37 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         55 my ( $client_id,$client_secret ) = _client_credentials_from_header( $self );
546              
547 13 100 66     89 if ( ! $client_id || ! $client_secret ) {
548 1         14 $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         637 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         271 $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         11558 return 0;
576             }
577              
578 51         246 return 1;
579             }
580              
581             sub _client_credentials_from_header {
582 25     25   62 my ( $self ) = @_;
583              
584 25         54 my ( $client_id,$client_secret );
585              
586             # params in the header
587 25 100       92 if ( my $auth_header = $self->req->headers->header( 'Authorization' ) ) {
    50          
588 7 50       268 if ( my ( $encoded_details ) = ( split( 'Basic ',$auth_header ) )[1] ) {
589 7   100     45 my $decoded_details = b64_decode( $encoded_details // '' );
590 7         32 ( $client_id,$client_secret ) = split( ':',$decoded_details, 2);
591 7         35 return ( $client_id,$client_secret );
592             }
593              
594             # params in the body
595             } elsif ( $client_id = $self->req->param('client_id') ) {
596 18         1505 $client_secret = $self->req->param('client_secret');
597             }
598              
599 18 50 33     943 if ( $client_id && $client_secret ) {
600 18         97 return ( $client_id,$client_secret );
601             }
602             }
603              
604             sub _verify_credentials {
605             my (
606 51     51   256 $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret,
607             $auth_code,$username,$password,$uri
608             ) = @_;
609              
610 51         147 my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description );
611              
612 51 100       320 if ( $grant_type eq 'refresh_token' ) {
    100          
    100          
613 9         88 ( $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         2569 $old_refresh_token = $refresh_token;
619              
620             } elsif ( $grant_type eq 'password' ) {
621 7         35 $scope = $self->every_param( 'scope' );
622              
623 7         570 ( $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         28 my $client_secret;
634              
635 12         32 ( $client,$client_secret ) = _client_credentials_from_header( $self );
636              
637 12         68 $scope = $self->every_param( 'scope' );
638 12         976 my $res;
639              
640 12         199 ( $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       2525 undef( $client ) if ! $res;
648              
649             } else {
650 23         232 ( $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     6064 : ( $client->{client_id} || $client->{client} );
662              
663 51         363 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