File Coverage

blib/lib/Mojolicious/Plugin/OAuth2.pm
Criterion Covered Total %
statement 121 123 98.3
branch 32 36 88.8
condition 23 36 63.8
subroutine 28 29 96.5
pod 1 1 100.0
total 205 225 91.1


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