File Coverage

blib/lib/Mojolicious/Plugin/OIDC.pm
Criterion Covered Total %
statement 94 112 83.9
branch 18 34 52.9
condition 8 20 40.0
subroutine 19 21 90.4
pod 1 1 100.0
total 140 188 74.4


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::OIDC;
2 3     3   4831395 use Mojo::Base 'Mojolicious::Plugin', -signatures;
  3         18430  
  3         30  
3              
4 3     3   11523 use Carp qw(croak);
  3         7  
  3         213  
5 3     3   2287 use Clone qw(clone);
  3         2180  
  3         309  
6 3     3   30 use Scalar::Util qw(blessed);
  3         8  
  3         202  
7 3     3   808 use Try::Tiny;
  3         2166  
  3         325  
8 3     3   1717 use OIDC::Client;
  3         3877411  
  3         165  
9 3     3   2426 use OIDC::Client::Plugin;
  3         962168  
  3         174  
10 3     3   40 use OIDC::Client::Error;
  3         31  
  3         100  
11 3     3   15 use OIDC::Client::Error::Authentication;
  3         5  
  3         4832  
12              
13             our $VERSION = '1.03'; # VERSION: generated by Dist::Zilla::Plugin::OurPkgVersion
14              
15             =encoding utf8
16              
17             =head1 NAME
18              
19             Mojolicious::Plugin::OIDC - OIDC protocol integration for Mojolicious
20              
21             =head1 DESCRIPTION
22              
23             This plugin makes it easy to integrate the OpenID Connect protocol
24             into a Mojolicious application.
25              
26             It essentially uses the L<OIDC-Client|https://metacpan.org/dist/OIDC-Client>
27             distribution.
28              
29             =cut
30              
31             has '_oidc_config';
32             has '_oidc_client_by_provider';
33              
34              
35             =head1 METHODS
36              
37             =head2 register
38              
39             Code executed once when the application is loaded.
40              
41             Depending on the configuration, creates and keeps in memory one or more clients
42             (L<OIDC::Client> stateless objects) and automatically adds the callback routes
43             to the application.
44              
45             =cut
46              
47 2     2 1 123 sub register ($self, $app, $config) {
  2         5  
  2         2  
  2         3  
  2         2  
48              
49             keys %$config
50 2 50 0     9 or $config = ($app->config->{oidc_client} || {});
51 2         8 $self->_oidc_config($config);
52              
53 2         18 my %client_by_provider;
54             my %seen_path;
55              
56 2 50       2 foreach my $provider (keys %{ $config->{provider} || {} }) {
  2         9  
57 2         48 my $config_provider = clone($config->{provider}{$provider});
58 2         21 $config_provider->{provider} = $provider;
59              
60 2         19 $client_by_provider{$provider} = OIDC::Client->new(
61             config => $config_provider,
62             log => $app->log,
63             );
64              
65             # dynamically add the callback routes to the application
66 2         44655 foreach my $action_type (qw/ login logout /) {
67             my $path = $action_type eq 'login' ? $config_provider->{signin_redirect_path}
68 4 100       512 : $config_provider->{logout_redirect_path};
69 4 100 66     20 next if !$path || $seen_path{$path}++;
70 1 50       4 my $method = $action_type eq 'login' ? '_login_callback' : '_logout_callback';
71 1 50       3 my $name = $action_type eq 'login' ? 'oidc_login_callback' : 'oidc_logout_callback';
72 1     2   10 $app->routes->any(['GET', 'POST'] => $path => sub { $self->$method(@_) } => $name);
  2         33073  
73             }
74             }
75 2         11 $self->_oidc_client_by_provider(\%client_by_provider);
76              
77 2     12   30 $app->helper('oidc' => sub { $self->_helper_oidc(@_) });
  12         150722  
78             }
79              
80              
81             =head1 METHODS ADDED TO THE APPLICATION
82              
83             =head2 oidc( $provider )
84              
85             # with just one provider
86             my $oidc = $c->oidc;
87             # or
88             my $oidc = $c->oidc('my_provider');
89              
90             # with several providers
91             my $oidc = $c->oidc('my_other_provider');
92             # or
93             my $oidc = $c->oidc; # here, you must define a default provider in the configuration
94              
95             Creates and returns an instance of L<OIDC::Client::Plugin> with the data
96             from the current request and session.
97              
98             If several providers are configured, the I<$provider> parameter is mandatory
99             unless the C<default_provider> configuration entry is defined (see L<OIDC::Client::Config>).
100              
101             This is the application's entry point to the library. Please see the
102             L<OIDC::Client::Plugin> documentation to find out what methods are available.
103              
104             =cut
105              
106 12     12   27 sub _helper_oidc ($self, $c, $provider = undef) {
  12         23  
  12         20  
  12         23  
  12         25  
107              
108 12         56 my $client = $self->_get_client_for_provider($provider);
109 12         37 my $plugin = $c->stash->{oidc}{plugin};
110              
111 12 100 66     274 return $plugin
112             if $plugin && $plugin->client->provider eq $client->provider;
113              
114             $plugin = $c->stash->{oidc}{plugin} = OIDC::Client::Plugin->new(
115             log => $c->log,
116             request_params => $c->req->params->to_hash,
117             request_headers => $c->req->headers->to_hash,
118             session => $c->session,
119             stash => $c->stash,
120 2     2   9462 redirect => sub { $c->redirect_to($_[0]); return; },
  2         1580  
121 8         39 client => $client,
122             base_url => $c->req->url->base->to_string,
123             current_url => $c->req->url->to_string,
124             );
125              
126 8         10475 return $plugin;
127             }
128              
129             # code executed on callback after authentication attempt
130 2     2   5 sub _login_callback ($self, $c) {
  2         5  
  2         5  
  2         4  
131              
132 2         10 my $auth_data = $self->_get_auth_data($c);
133              
134             try {
135 2     2   122 $c->oidc($auth_data->{provider})->get_token();
136 1   33     18716 $c->redirect_to($auth_data->{target_url} || $c->url_for('/'));
137             }
138             catch {
139 1     1   32175 my $e = $_;
140 1 50 33     13 die $e unless blessed($e) && $e->isa('OIDC::Client::Error');
141 1 50       6 if (my $error_path = $self->_oidc_config->{authentication_error_path}) {
142 1         54 $c->flash('error_message' => $e->message);
143 1         73 $c->redirect_to($c->url_for($error_path));
144             }
145             else {
146 0         0 OIDC::Client::Error::Authentication->throw($e->message);
147             }
148 2         31 };
149             }
150              
151 2     2   3 sub _get_auth_data ($self, $c) {
  2         5  
  2         5  
  2         17  
152 2 50       7 my $state = $c->req->param('state')
153             or OIDC::Client::Error::Authentication->throw("OIDC: no state parameter in login callback request");
154              
155 2 50       867 my $auth_data = $c->session->{oidc_auth}{$state}
156             or OIDC::Client::Error::Authentication->throw("OIDC: no authorisation data");
157              
158 2         815 return $auth_data;
159             }
160              
161             # code executed on callback after user logout
162 0     0   0 sub _logout_callback ($self, $c) {
  0         0  
  0         0  
  0         0  
163              
164 0         0 $c->log->debug('Logging out');
165 0         0 my $logout_data = $self->_extract_logout_data($c);
166              
167 0         0 $c->oidc($logout_data->{provider})->delete_stored_data();
168              
169 0   0     0 $c->redirect_to($logout_data->{target_url} || $c->url_for('/'));
170             }
171              
172 0     0   0 sub _extract_logout_data ($self, $c) {
  0         0  
  0         0  
  0         0  
173 0 0       0 my $state = $c->req->param('state')
174             or OIDC::Client::Error->throw("OIDC: no state parameter in logout callback request");
175              
176 0 0       0 my $logout_data = delete $c->session->{oidc_logout}{$state}
177             or OIDC::Client::Error->throw("OIDC: no logout data");
178              
179 0         0 return $logout_data;
180             }
181              
182 12     12   20 sub _get_client_for_provider ($self, $provider) {
  12         21  
  12         21  
  12         12  
183 12   66     80 $provider //= $self->_oidc_config->{default_provider};
184              
185 12 100       82 unless (defined $provider) {
186 10         16 my @providers = keys %{ $self->_oidc_client_by_provider };
  10         33  
187 10 50       64 if (@providers == 1) {
    0          
188 10         19 $provider = $providers[0];
189             }
190             elsif (@providers > 1) {
191 0         0 croak(q{OIDC: more than one provider are configured, the provider is mandatory : $c->oidc('my_provider')});
192             }
193             else {
194 0         0 croak("OIDC: no provider configured");
195             }
196             }
197              
198 12 50       32 my $client = $self->_oidc_client_by_provider->{$provider}
199             or croak("OIDC: no client for provider $provider");
200              
201 12         90 return $client;
202             }
203              
204             =head1 CONFIGURATION
205              
206             Section to be added to your configuration file :
207              
208             oidc_client => {
209             provider => {
210             provider_name => {
211             id => 'my-app-id',
212             secret => 'xxxxxxxxx',
213             well_known_url => 'https://yourprovider.com/oauth2/.well-known/openid-configuration',
214             signin_redirect_path => '/oidc/login/callback',
215             scope => 'openid profile roles email',
216             expiration_leeway => 20,
217             claim_mapping => {
218             login => 'sub',
219             lastname => 'lastName',
220             firstname => 'firstName',
221             email => 'email',
222             roles => 'roles',
223             },
224             audience_alias => {
225             other_app_name => {
226             audience => 'other-app-audience',
227             }
228             }
229             }
230             }
231             }
232              
233             This is an example, see the detailed possibilities in L<OIDC::Client::Config>.
234              
235             =head1 SAMPLES
236              
237             Here are some samples by category. Although you will have to adapt them to your needs,
238             they should be a good starting point.
239              
240             =head2 Setup
241              
242             To setup the plugin when the application is launched :
243              
244             $app->plugin('OIDC');
245              
246             =head2 Authentication
247              
248             To authenticate the end-user :
249              
250             $app->hook(before_dispatch => sub {
251             my $c = shift;
252              
253             my $path = $c->req->url->path;
254              
255             # Public routes
256             return if $path =~ m[^/oidc/]
257             || $path =~ m[^/error/];
258              
259             # Authentication
260             if (my $identity = $c->oidc->get_valid_identity()) {
261             $c->remote_user($identity->subject);
262             }
263             elsif (uc($c->req->method) eq 'GET' && !$c->is_ajax_request()) {
264             $c->oidc->redirect_to_authorize();
265             }
266             else {
267             $c->render(template => 'error',
268             message => "You have been logged out. Please try again after refreshing the page.",
269             status => 401);
270             }
271             });
272              
273             =head2 API call
274              
275             To make an API call with propagation of the security context (token exchange) :
276              
277             # Retrieving a web client (Mojo::UserAgent object)
278             my $ua = try {
279             $c->oidc->build_api_useragent('other_app_name')
280             }
281             catch {
282             $c->log->warn("Unable to exchange token : $_");
283             $c->render(template => 'error',
284             message => "Authorization problem. Please try again after refreshing the page.",
285             status => 403);
286             return;
287             } or return;
288              
289             # Usual call to the API
290             my $res = $ua->get($url)->result;
291              
292             =head2 Resource Server
293              
294             To check an access token from a Resource Server, assuming it's a JWT token.
295             For example, with an application using L<Mojolicious::Plugin::OpenAPI>, you can
296             define a security definition that checks that the access token is intended for all
297             the expected scopes :
298              
299             $app->plugin(OpenAPI => {
300             url => "data:///swagger.yaml",
301             security => {
302             oidc => sub {
303             my ($c, $definition, $scopes, $cb) = @_;
304              
305             my $access_token = try {
306             return $c->oidc->verify_token();
307             }
308             catch {
309             $c->log->warn("Token validation : $_");
310             return;
311             } or return $c->$cb("Invalid or incomplete token");
312              
313             foreach my $expected_scope (@$scopes) {
314             unless ($access_token->has_scope($expected_scope)) {
315             return $c->$cb("Insufficient scopes");
316             }
317             }
318              
319             return $c->$cb();
320             },
321             }
322             });
323              
324             Another security definition that checks that the user has at least
325             one expected role :
326              
327             $app->plugin(OpenAPI => {
328             url => "data:///swagger.yaml",
329             security => {
330             oidc => sub {
331             my ($c, $definition, $roles_to_check, $cb) = @_;
332              
333             my $user = try {
334             my $access_token = $c->oidc->verify_token();
335             return $c->oidc->build_user_from_claims($access_token->claims);
336             }
337             catch {
338             $c->log->warn("Token/User validation : $_");
339             return;
340             } or return $c->$cb('Unauthorized');
341              
342             foreach my $role_to_check (@$roles_to_check) {
343             if ($user->has_role($role_to_check)) {
344             return $c->$cb();
345             }
346             }
347              
348             return $c->$cb("Insufficient roles");
349             },
350             }
351             });
352              
353             =head1 SECURITY RECOMMENDATION
354              
355             It is highly recommended to configure the framework to store session data,
356             including sensitive tokens such as access and refresh tokens, on the backend
357             rather than in client-side cookies. Although cookies can be signed and encrypted,
358             storing tokens in the client exposes them to potential security threats.
359              
360             =head1 AUTHOR
361              
362             Sébastien Mourlhou
363              
364             =head1 COPYRIGHT AND LICENSE
365              
366             Copyright (C) Sébastien Mourlhou
367              
368             This program is free software, you can redistribute it and/or modify it under the terms of the Artistic License version 2.0.
369              
370             =head1 SEE ALSO
371              
372             =over 2
373              
374             =item * L<Mojolicious::Plugin::OAuth2>
375              
376             =back
377              
378             =cut
379              
380             1;