File Coverage

blib/lib/Mojolicious/Plugin/OAuth2.pm
Criterion Covered Total %
statement 129 131 98.4
branch 36 42 85.7
condition 23 36 63.8
subroutine 31 32 96.8
pod 1 1 100.0
total 220 242 90.9


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::OAuth2;
2 5     5   478047 use Mojo::Base 'Mojolicious::Plugin';
  5         14  
  5         31  
3              
4 5     5   1537 use Carp qw(croak);
  5         11  
  5         216  
5 5     5   24 use Mojo::Promise;
  5         6  
  5         47  
6 5     5   96 use Mojo::URL;
  5         8  
  5         34  
7 5     5   98 use Mojo::UserAgent;
  5         9  
  5         38  
8              
9 5     5   188 use constant MOJO_JWT => eval 'use Mojo::JWT 0.09; use Crypt::OpenSSL::RSA; use Crypt::OpenSSL::Bignum; 1';
  5     5   8  
  5     5   431  
  5     5   2142  
  5         11999  
  5         50  
  5         2405  
  5         10725  
  5         186  
  5         46  
  5         9  
  5         73  
10              
11             our @CARP_NOT = qw(Mojolicious::Plugin::OAuth2 Mojolicious::Renderer);
12             our $VERSION = '2.02';
13              
14             has providers => sub {
15             return {
16             dailymotion => {
17             authorize_url => 'https://api.dailymotion.com/oauth/authorize',
18             token_url => 'https://api.dailymotion.com/oauth/token'
19             },
20             debian_salsa => {
21             authorize_url => 'https://salsa.debian.org/oauth/authorize?response_type=code',
22             token_url => 'https://salsa.debian.org/oauth/token',
23             },
24             eventbrite => {
25             authorize_url => 'https://www.eventbrite.com/oauth/authorize',
26             token_url => 'https://www.eventbrite.com/oauth/token',
27             },
28             facebook => {
29             authorize_url => 'https://graph.facebook.com/oauth/authorize',
30             token_url => 'https://graph.facebook.com/oauth/access_token',
31             },
32             instagram => {
33             authorize_url => 'https://api.instagram.com/oauth/authorize/?response_type=code',
34             token_url => 'https://api.instagram.com/oauth/access_token',
35             },
36             github => {
37             authorize_url => 'https://github.com/login/oauth/authorize',
38             token_url => 'https://github.com/login/oauth/access_token',
39             },
40             google => {
41             authorize_url => 'https://accounts.google.com/o/oauth2/v2/auth?response_type=code',
42             token_url => 'https://www.googleapis.com/oauth2/v4/token',
43             },
44             vkontakte => {authorize_url => 'https://oauth.vk.com/authorize', token_url => 'https://oauth.vk.com/access_token',},
45             mocked => {authorize_url => '/mocked/oauth/authorize', token_url => '/mocked/oauth/token', secret => 'fake_secret'},
46             };
47             };
48              
49             has _ua => sub { Mojo::UserAgent->new };
50              
51             sub register {
52 5     5 1 385 my ($self, $app, $config) = @_;
53              
54 5 100       22 if ($config->{providers}) {
55 2         9 $self->_config_to_providers($config->{providers});
56 2 50       22 $self->_ua($config->{ua}) if $config->{ua};
57 2 50       15 $self->_ua->proxy->detect if $config->{proxy};
58             }
59             else {
60 3         11 $self->_config_to_providers($config);
61             }
62              
63 5     3   52 $app->helper('oauth2.auth_url' => sub { $self->_call(_auth_url => @_) });
  3         40747  
64 5     3   1729 $app->helper('oauth2.get_refresh_token_p' => sub { $self->_call(_get_refresh_token_p => @_) });
  3         33877  
65 5     15   1125 $app->helper('oauth2.get_token_p' => sub { $self->_call(_get_token_p => @_) });
  15         293200  
66 5     3   1187 $app->helper('oauth2.jwt_decode' => sub { $self->_call(_jwt_decode => @_) });
  3         2856  
67 5     2   1332 $app->helper('oauth2.logout_url' => sub { $self->_call(_logout_url => @_) });
  2         21285  
68 5     4   1493 $app->helper('oauth2.providers' => sub { $self->providers });
  4         28844  
69              
70 5 100       1504 $self->_apply_mock($self->providers->{mocked}) if $self->providers->{mocked}{key};
71 5         68 $self->_warmup_openid($app);
72             }
73              
74             sub _apply_mock {
75 2     2   23 my ($self, $provider_args) = @_;
76              
77 2         1063 require Mojolicious::Plugin::OAuth2::Mock;
78 2         10 require Mojolicious;
79 2   33     10 my $app = $self->_ua->server->app || Mojolicious->new;
80 2         138 Mojolicious::Plugin::OAuth2::Mock->apply_to($app, $provider_args);
81 2         506 $self->_ua->server->app($app);
82             }
83              
84             sub _auth_url {
85 8     8   29 my ($self, $c, $args) = @_;
86 8         23 my $provider_args = $self->providers->{$args->{provider}};
87 8         38 my $authorize_url;
88              
89 8   100     59 $args->{scope} ||= $provider_args->{scope};
90 8   66     36 $args->{redirect_uri} ||= $c->url_for->to_abs->to_string;
91 8         4084 $authorize_url = Mojo::URL->new($provider_args->{authorize_url});
92 8 100       542 $authorize_url->host($args->{host}) if exists $args->{host};
93 8         26 $authorize_url->query->append(client_id => $provider_args->{key}, redirect_uri => $args->{redirect_uri});
94 8 100       271 $authorize_url->query->append(scope => $args->{scope}) if defined $args->{scope};
95 8 100       128 $authorize_url->query->append(state => $args->{state}) if defined $args->{state};
96 8 100       44 $authorize_url->query($args->{authorize_query}) if exists $args->{authorize_query};
97 8         459 $authorize_url;
98             }
99              
100             sub _call {
101 26     26   97 my ($self, $method, $c, $provider) = (shift, shift, shift, shift);
102 26 100       128 my $args = @_ % 2 ? shift : {@_};
103 26   100     130 $args->{provider} = $provider || 'unknown';
104 26 100       101 croak "Invalid provider: $args->{provider}" unless $self->providers->{$args->{provider}};
105 25         243 return $self->$method($c, $args);
106             }
107              
108             sub _config_to_providers {
109 5     5   9 my ($self, $config) = @_;
110              
111 5         78 for my $provider (keys %$config) {
112 5   100     19 my $p = $self->providers->{$provider} ||= {};
113 5         13 for my $key (keys %{$config->{$provider}}) {
  5         17  
114 14         29 $p->{$key} = $config->{$provider}{$key};
115             }
116             }
117             }
118              
119             sub _get_refresh_token_p {
120 3     3   6 my ($self, $c, $args) = @_;
121              
122             # TODO: Handle error response from oidc provider callback URL, if possible
123 3   66     10 my $err = $c->param('error_description') || $c->param('error');
124 3 100       724 return Mojo::Promise->reject($err) if $err;
125              
126 2         6 my $provider_args = $self->providers->{$args->{provider}};
127             my $params = {
128             client_id => $provider_args->{key},
129             client_secret => $provider_args->{secret},
130             grant_type => 'refresh_token',
131             refresh_token => $args->{refresh_token},
132             scope => $provider_args->{scope},
133 2         20 };
134              
135 2         9 my $token_url = Mojo::URL->new($provider_args->{token_url});
136 2 50       169 $token_url->host($args->{host}) if exists $args->{host};
137              
138 2     2   8 return $self->_ua->post_p($token_url, form => $params)->then(sub { $self->_parse_provider_response(@_) });
  2         12188  
139             }
140              
141             sub _get_token_p {
142 15     15   42 my ($self, $c, $args) = @_;
143              
144             # Handle error response from provider callback URL
145 15   66     54 my $err = $c->param('error_description') || $c->param('error');
146 15 100       3055 return Mojo::Promise->reject($err) if $err;
147              
148             # No error or code response from provider callback URL
149 13 100       52 unless ($c->param('code')) {
150 7 100 100     368 $c->redirect_to($self->_auth_url($c, $args)) if $args->{redirect} // 1;
151 7         1300 return Mojo::Promise->resolve(undef);
152             }
153              
154             # Handle "code" from provider callback
155 6         250 my $provider_args = $self->providers->{$args->{provider}};
156             my $params = {
157             client_id => $provider_args->{key},
158             client_secret => $provider_args->{secret},
159             code => scalar($c->param('code')),
160             grant_type => 'authorization_code',
161 6   66     65 redirect_uri => $args->{redirect_uri} || $c->url_for->to_abs->to_string,
162             };
163              
164 6 100       2276 $params->{state} = $c->param('state') if $c->param('state');
165              
166 6         503 my $token_url = Mojo::URL->new($provider_args->{token_url});
167 6 50       409 $token_url->host($args->{host}) if exists $args->{host};
168              
169 6     6   28 return $self->_ua->post_p($token_url, form => $params)->then(sub { $self->_parse_provider_response(@_) });
  6         49913  
170             }
171              
172             sub _jwt_decode {
173 3   33 3   15 my $peek = ref $_[-1] eq 'CODE' && pop;
174 3         25 my ($self, $c, $args) = @_;
175 3 50       8 croak 'Provider does not have "jwt" defined.' unless my $jwt = $self->providers->{$args->{provider}}{jwt};
176 3         26 return $jwt->decode($args->{data}, $peek);
177             }
178              
179             sub _logout_url {
180 2     2   5 my ($self, $c, $args) = @_;
181             return Mojo::URL->new($self->providers->{$args->{provider}}{end_session_url})->tap(
182             query => {
183             post_logout_redirect_uri => $args->{post_logout_redirect_uri},
184             id_token_hint => $args->{id_token_hint},
185             state => $args->{state}
186             }
187 2         6 );
188             }
189              
190             sub _parse_provider_response {
191 8     8   22 my ($self, $tx) = @_;
192 8   50     29 my $code = $tx->res->code || 'No response';
193              
194             # Will cause the promise to be rejected
195 8 50 0     102 return Mojo::Promise->reject(sprintf '%s == %s', $tx->req->url, $tx->error->{message} // $code) if $code ne '200';
196 8 100       23 return $tx->res->headers->content_type =~ m!^(application/json|text/javascript)(;\s*charset=\S+)?$!
197             ? $tx->res->json
198             : Mojo::Parameters->new($tx->res->body)->to_hash;
199             }
200              
201             sub _warmup_openid {
202 5     5   13 my ($self, $app) = (shift, shift);
203              
204 5         15 my ($providers, @p) = ($self->providers);
205 5         31 for my $provider (values %$providers) {
206 47 100       4789 next unless $provider->{well_known_url};
207 1         9 $app->log->debug("Fetching OpenID configuration from $provider->{well_known_url}");
208 1         83 push @p, $self->_warmup_openid_provider_p($app, $provider);
209             }
210              
211 5   100     44 return @p && Mojo::Promise->all(@p)->wait;
212             }
213              
214             sub _warmup_openid_provider_p {
215 1     1   4 my ($self, $app, $provider) = @_;
216              
217             return $self->_ua->get_p($provider->{well_known_url})->then(sub {
218 1     1   9907 my $tx = shift;
219 1         7 my $res = $tx->result->json;
220 1         864 $provider->{authorize_url} = $res->{authorization_endpoint};
221 1         2 $provider->{end_session_url} = $res->{end_session_endpoint};
222 1         3 $provider->{issuer} = $res->{issuer};
223 1         3 $provider->{token_url} = $res->{token_endpoint};
224 1         3 $provider->{userinfo_url} = $res->{userinfo_endpoint};
225 1   50     6 $provider->{scope} //= 'openid';
226              
227 1         18 return $self->_ua->get_p($res->{jwks_uri});
228             })->then(sub {
229 1     1   6789 my $tx = shift;
230 1         12 $provider->{jwt} = Mojo::JWT->new->add_jwkset($tx->result->json);
231 1         643 return $provider;
232             })->catch(sub {
233 0     0     my $err = shift;
234 0           $app->log->error("[OAuth2] Failed to warm up $provider->{well_known_url}: $err");
235 1         4 });
236             }
237              
238             1;
239              
240             =head1 NAME
241              
242             Mojolicious::Plugin::OAuth2 - Auth against OAuth2 APIs including OpenID Connect
243              
244             =head1 SYNOPSIS
245              
246             =head2 Example application
247              
248             use Mojolicious::Lite;
249              
250             plugin OAuth2 => {
251             providers => {
252             facebook => {
253             key => 'some-public-app-id',
254             secret => $ENV{OAUTH2_FACEBOOK_SECRET},
255             },
256             },
257             };
258              
259             get '/connect' => sub {
260             my $c = shift;
261             my %get_token = (redirect_uri => $c->url_for('connect')->userinfo(undef)->to_abs);
262              
263             return $c->oauth2->get_token_p(facebook => \%get_token)->then(sub {
264             # Redirected to Facebook
265             return unless my $provider_res = shift;
266              
267             # Token received
268             $c->session(token => $provider_res->{access_token});
269             $c->redirect_to('profile');
270             })->catch(sub {
271             $c->render('connect', error => shift);
272             });
273             };
274              
275             See L for more details about the configuration this plugin takes.
276              
277             =head2 Testing
278              
279             Code using this plugin can perform offline testing, using the "mocked"
280             provider:
281              
282             $app->plugin(OAuth2 => {mocked => {key => 42}});
283             $app->routes->get('/profile' => sub {
284             my $c = shift;
285              
286             state $mocked = $ENV{TEST_MOCKED} && 'mocked';
287             return $c->oauth2->get_token_p($mocked || 'facebook')->then(sub {
288             ...
289             });
290             });
291              
292             See L for more details.
293              
294             =head2 Connect button
295              
296             You can add a "connect link" to your template using the L
297             helper. Example template:
298              
299             Click here to log in:
300             <%= link_to 'Connect!', $c->oauth2->auth_url('facebook', scope => 'user_about_me email') %>
301              
302             =head1 DESCRIPTION
303              
304             This Mojolicious plugin allows you to easily authenticate against a
305             L or L
306             provider. It includes configurations for a few popular L,
307             but you can add your own as well.
308              
309             See L for a full list of bundled providers.
310              
311             To support "OpenID Connect", the following optional modules must be installed
312             manually: L, L and L.
313             The modules can be installed with L:
314              
315             $ cpanm Crypt::OpenSSL::Bignum Crypt::OpenSSL::RSA Mojo::JWT
316              
317             =head1 HELPERS
318              
319             =head2 oauth2.auth_url
320              
321             $url = $c->oauth2->auth_url($provider_name => \%args);
322              
323             Returns a L object which contain the authorize URL. This is
324             useful if you want to add the authorize URL as a link to your webpage
325             instead of doing a redirect like L does. C<%args> is optional,
326             but can contain:
327              
328             =over 2
329              
330             =item * host
331              
332             Useful if your provider uses different hosts for accessing different accounts.
333             The default is specified in the provider configuration.
334              
335             $url->host($host);
336              
337             =item * authorize_query
338              
339             Either a hash-ref or an array-ref which can be used to give extra query
340             params to the URL.
341              
342             $url->query($authorize_url);
343              
344             =item * redirect_uri
345              
346             Useful if you want to go back to a different page than what you came from.
347             The default is:
348              
349             $c->url_for->to_abs->to_string
350              
351             =item * scope
352              
353             Scope to ask for credentials to. Should be a space separated list.
354              
355             =item * state
356              
357             A string that will be sent to the identity provider. When the user returns
358             from the identity provider, this exact same string will be carried with the user,
359             as a GET parameter called C in the URL that the user will return to.
360              
361             =back
362              
363             =head2 oauth2.get_refresh_token_p
364              
365             $promise = $c->oauth2->get_refresh_token_p($provider_name => \%args);
366              
367             When L is being used in OpenID Connect mode this
368             helper allows for a token to be refreshed by specifying a C in
369             C<%args>. Usage is similar to L.
370              
371             =head2 oauth2.get_token_p
372              
373             $promise = $c->oauth2->get_token_p($provider_name => \%args)
374             ->then(sub { my $provider_res = shift })
375             ->catch(sub { my $err = shift; });
376              
377             L is used to either fetch an access token from an OAuth2
378             provider, handle errors or redirect to OAuth2 provider. C<$err> in the
379             rejection handler holds a error description if something went wrong.
380             C<$provider_res> is a hash-ref containing the access token from the OAauth2
381             provider or C if this plugin performed a 302 redirect to the provider's
382             connect website.
383              
384             In more detail, this method will do one of two things:
385              
386             =over 2
387              
388             =item 1.
389              
390             When called from an action on your site, it will redirect you to the provider's
391             C. This site will probably have some sort of "Connect" and
392             "Reject" button, allowing the visitor to either connect your site with his/her
393             profile on the OAuth2 provider's page or not.
394              
395             =item 2.
396              
397             The OAuth2 provider will redirect the user back to your site after clicking the
398             "Connect" or "Reject" button. C<$provider_res> will then contain a key
399             "access_token" on "Connect" and a false value on "Reject".
400              
401             =back
402              
403             The method takes these arguments: C<$provider_name> need to match on of
404             the provider names under L or a custom provider defined
405             when L the plugin.
406              
407             C<%args> can have:
408              
409             =over 2
410              
411             =item * host
412              
413             Useful if your provider uses different hosts for accessing different accounts.
414             The default is specified in the provider configuration.
415              
416             =item * redirect
417              
418             Set C to 0 to disable automatic redirect.
419              
420             =item * scope
421              
422             Scope to ask for credentials to. Should be a space separated list.
423              
424             =back
425              
426             =head2 oauth2.jwt_decode
427              
428             $claims = $c->oauth2->jwt_decode($provider, sub { my $jwt = shift; ... });
429             $claims = $c->oauth2->jwt_decode($provider);
430              
431             When L is being used in OpenID Connect mode this
432             helper allows you to decode the response data encoded with the JWKS discovered
433             from C configuration.
434              
435             =head2 oauth2.logout_url
436              
437             $url = $c->oauth2->logout_url($provider_name => \%args);
438              
439             When L is being used in OpenID Connect mode this
440             helper creates the url to redirect to end the session. The OpenID Connect
441             Provider will redirect to the C provided in C<%args>.
442             Additional keys for C<%args> are C and C.
443              
444             =head2 oauth2.providers
445              
446             $hash_ref = $c->oauth2->providers;
447              
448             This helper allow you to access the raw providers mapping, which looks
449             something like this:
450              
451             {
452             facebook => {
453             authorize_url => "https://graph.facebook.com/oauth/authorize",
454             token_url => "https://graph.facebook.com/oauth/access_token",
455             key => ...,
456             secret => ...,
457             },
458             ...
459             }
460              
461             =head1 ATTRIBUTES
462              
463             =head2 providers
464              
465             $hash_ref = $oauth2->providers;
466              
467             Holds a hash of provider information. See L.
468              
469             =head1 METHODS
470              
471             =head2 register
472              
473             $app->plugin(OAuth2 => \%provider_config);
474             $app->plugin(OAuth2 => {providers => \%provider_config, proxy => 1, ua => Mojo::UserAgent->new});
475              
476             Will register this plugin in your application with a given C<%provider_config>.
477             The keys in C<%provider_config> are provider names and the values are
478             configuration for each provider. Note that the value will be merged with the
479             predefined providers below.
480              
481             Instead of just passing in C<%provider_config>, it is possible to pass in a
482             more complex config, with these keys:
483              
484             =over 2
485              
486             =item * providers
487              
488             The C<%provider_config> must be present under this key.
489              
490             =item * proxy
491              
492             Setting this to a true value will automatically detect proxy settings using
493             L.
494              
495             =item * ua
496              
497             A custom L, in case you want to change proxy settings,
498             timeouts or other attributes.
499              
500             =back
501              
502             Instead of just passing in C<%provider_config>, it is possible to pass in a
503             hash-ref "providers" (C<%provider_config>) and "ua" (a custom
504             L object).
505              
506             Here is an example to add adddition information like "key" and "secret":
507              
508             $app->plugin(OAuth2 => {
509             providers => {
510             custom_provider => {
511             key => 'APP_ID',
512             secret => 'SECRET_KEY',
513             authorize_url => 'https://provider.example.com/auth',
514             token_url => 'https://provider.example.com/token',
515             },
516             github => {
517             key => 'APP_ID',
518             secret => 'SECRET_KEY',
519             },
520             },
521             });
522              
523             For L, C and C are configured from the
524             C so these are replaced by the C key.
525              
526             $app->plugin(OAuth2 => {
527             providers => {
528             azure_ad => {
529             key => 'APP_ID',
530             secret => 'SECRET_KEY',
531             well_known_url => 'https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration',
532             },
533             },
534             });
535              
536             To make it a bit easier the are already some predefined providers bundled with
537             this plugin:
538              
539             =head3 dailymotion
540              
541             Authentication for L video site.
542              
543             =head3 debian_salsa
544              
545             Authentication for L.
546              
547             =head3 eventbrite
548              
549             Authentication for L event site.
550              
551             See also L.
552              
553             =head3 facebook
554              
555             OAuth2 for Facebook's graph API, L. You can find
556             C (App ID) and C (App Secret) from the app dashboard here:
557             L.
558              
559             See also L.
560              
561             =head3 instagram
562              
563             OAuth2 for Instagram API. You can find C (Client ID) and
564             C (Client Secret) from the app dashboard here:
565             L.
566              
567             See also L.
568              
569             =head3 github
570              
571             Authentication with Github.
572              
573             See also L.
574              
575             =head3 google
576              
577             OAuth2 for Google. You can find the C (CLIENT ID) and C
578             (CLIENT SECRET) from the app console here under "APIs & Auth" and
579             "Credentials" in the menu at L.
580              
581             See also L.
582              
583             =head3 vkontakte
584              
585             OAuth2 for Vkontakte. You can find C (App ID) and C
586             (Secure key) from the app dashboard here: L.
587              
588             See also L.
589              
590             =head1 AUTHOR
591              
592             Marcus Ramberg - C
593              
594             Jan Henning Thorsen - C
595              
596             =head1 LICENSE
597              
598             This software is licensed under the same terms as Perl itself.
599              
600             =head1 SEE ALSO
601              
602             =over 2
603              
604             =item * L
605              
606             =item * L
607              
608             =item * L
609              
610             =item * L
611              
612             =item * L
613              
614             =back
615              
616             =cut