File Coverage

blib/lib/Mojolicious/Plugin/Authentication/OIDC.pm
Criterion Covered Total %
statement 23 93 24.7
branch 0 22 0.0
condition 0 15 0.0
subroutine 8 15 53.3
pod 1 1 100.0
total 32 146 21.9


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::Authentication::OIDC 0.06;
2 1     1   310692 use v5.26;
  1         5  
3 1     1   7 use warnings;
  1         4  
  1         114  
4              
5             # ABSTRACT: OpenID Connect implementation integrated into Mojolicious
6              
7             =encoding UTF-8
8              
9             =head1 NAME
10              
11             Mojolicious::Plugin::Authentication::OIDC - OpenID Connect implementation
12             integrated into Mojolicious
13              
14             =head1 SYNOPSIS
15              
16             $self->plugin('Authentication::OIDC' => {
17             client_secret => '...',
18             well_known_url => 'https://idp/realms/master/.well-known/openid-configuration',
19             public_key => "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
20             });
21              
22             # in controller
23             say "Hi " . $c->authn->current_user->firstname;
24              
25             use Array::Utils qw(intersect);
26             if(intersect($c->authn->current_user_roles->@*, qw(admin))) { ... }
27              
28             =head1 DESCRIPTION
29              
30             Mojolicious plugin for L
31             authentication. Designed to work with an OpenID Connect provider like Keycloak,
32             in confidential access mode. Its design goal is to be configured "all at once"
33             and then little-to-no effort should be needed to support it elsewhere -- this is
34             largely achieved via hooks (L, L, L,
35             L) and handlers (L, L, L)
36              
37             Controller actions C and C are
38             registered, and can be mapped to routes implicitly (see L) or
39             manually routed to (e.g., via L).
40              
41             For the auth workflow, clients should be sent to C (via
42             L if L is enabled). Once the client authenticates
43             to the identity server, they'll be sent to C (via
44             L, again, if L is enabled). Upon successful login, the
45             L hook will be called, followed by the L hook, which
46             should send the client to a post-login page. Then, on every server access
47             on/after login, the L hook will be called.
48              
49             Controllers may get information about the logged in user via the
50             L and L helper methods.
51              
52             =cut
53              
54 1     1   884 use Mojo::Base 'Mojolicious::Plugin';
  1         13785  
  1         9  
55              
56 1     1   2596 use Crypt::JWT qw(decode_jwt);
  1         82069  
  1         110  
57 1     1   1052 use Mojo::UserAgent;
  1         646521  
  1         7  
58 1     1   703 use Readonly;
  1         4044  
  1         58  
59 1     1   539 use Syntax::Keyword::Try;
  1         2568  
  1         6  
60              
61 1     1   614 use experimental qw(signatures);
  1         4498  
  1         5  
62              
63             Readonly::Array my @REQUIRED_PARAMS => qw(
64             client_secret
65             well_known_url
66             public_key
67             );
68             Readonly::Array my @ALLOWED_PARAMS => qw(
69             client_id on_login on_activity base_url
70             );
71             Readonly::Hash my %DEFAULT_PARAMS => (
72             login_path => '/auth/login',
73             redirect_path => '/auth',
74             make_routes => 1,
75              
76             on_success => sub ($c, $token, $url) {$c->session(token => $token); $c->redirect_to($url)},
77             on_error => sub ($c, $error) {$c->render(json => $error)},
78              
79             get_token => sub ($c) {$c->session('token')},
80             get_user => sub ($token) {$token},
81             get_roles => sub ($user, $token) {$user ? [] : undef},
82              
83             role_map => undef,
84             );
85             Readonly::Hash my %DEFAULT_CONSTANTS => (
86             scope => 'openid',
87             response_type => 'code',
88             grant_type => 'authorization_code',
89             );
90             Readonly::Scalar my $DEFAULT_PREFIX => 'authn';
91              
92             =head1 METHODS
93              
94             L inherits all methods from
95             L and implements the following new ones
96              
97             =head2 register( \%params )
98              
99             Register plugin in L application, including registering
100             L as a
101             Mojolicious controller.
102              
103             Configuration is done via the C<\%params> HashRef, given the following keys
104              
105             =head4 make_routes
106              
107             B
108              
109             A flag to indicate whether routes should be registered for L and
110             L pointing to
111             L
112             and L,
113             respectively. Set to false if you are handling these routes some other way, for
114             instance in a L spec.
115              
116             Default: true
117              
118             =head4 client_id
119              
120             B
121              
122             The application's unique identifier, to the auhorization server
123              
124             Default: the application moniker (lowercase)
125              
126             =head4 client_secret
127              
128             B
129              
130             A secret value known to the authorization server, specific to this application
131              
132             =head4 well_known_url
133              
134             B
135              
136             A discovery endpoint that informs the application of the authorization server's
137             specific configuration and capabilities
138              
139             Example: C
140              
141             =head4 public_key
142              
143             B
144              
145             The RSA public key corresponding to the private key with which the authorization
146             tokens are signed by the authorization server. Format is a PEM/DER/JWT string
147             accepted by L's C parameter.
148              
149             =head4 redirect_path
150              
151             B
152              
153             The path for the route which redirects users to the authorization server. Only
154             used when L is enabled, for configuring the path of that route.
155              
156             Default: C
157              
158             =head4 login_path
159              
160             B
161              
162             The path section of the URL to be used as the OIDC C. The full
163             URL will be constructed based on the scheme/host/port of the request. This
164             path is also used for route registration if L is enabled.
165              
166             Default: C
167              
168             =head4 get_user ( $token )
169              
170             B
171              
172             A callback that's given the auth token data as its only argument. It should
173             return the application's C instance corresponding to that data (e.g., a
174             database record object), creating any references as necessary.
175              
176             Default: Simply returns the auth token data as a HashRef
177              
178              
179             =head4 get_token ( $controller )
180              
181             B
182              
183             A callback that's given a Mojolicious controller as its only argument. It should
184             return the encoded authorization token. This allows the application to choose
185             where and how the token is stored on the client side and sent to the server.
186              
187             Default: returns the C value of the Mojo session
188              
189             =head4 get_roles ( $user, $token )
190              
191             B
192              
193             Given a C instance (produced by L) and a decoded authorization
194             token as arguments, returns an ArrayRef of roles pertaining to that user.
195              
196             Default: returns an empty ArrayRef
197              
198             =head4 role_map (%map)
199              
200             B
201              
202             A mapping of external roles (e.g., from Authorization Server) to internal roles
203             used by the application's authorization framework. If this option is specified,
204             roles not present in its keys are deleted, all others will be mapped.
205              
206             =head4 on_login ( $controller, $user )
207              
208             B
209              
210             A hook to allow the application to respond to a successful user login, such as
211             updating the user's C date.
212              
213             Default: C
214              
215             =head4 on_activity ( $controller, $user )
216              
217             B
218              
219             A hook to allow the application to respond to any request by a logged-in user,
220             such as updating the user's C date.
221              
222             Default: C
223              
224             =head4 on_success ( $controller, $token )
225              
226             B
227              
228             A hook invoked when the user's authorization request succeeds. This code should
229             address storage of the authentication token on the frontend.
230              
231             Default: Stores the (encrypted) token in the C key of the Mojo session
232             and redirects to C
233              
234             =head4 on_error
235              
236             B
237              
238             A hook invoked when the user's authorization request fails.
239              
240             Default: renders the Authorization Server's response (JSON)
241              
242             =head2 current_user
243              
244             Returns the user record (from L) for the currently logged in user.
245             If no user is logged in, or any failure occurs in reading their access token,
246             returns C
247              
248             =head2 current_user_roles
249              
250             If a user is logged in, returns their roles (as determined by L).
251             Otherwise, returns C
252              
253             =cut
254              
255 0     0 1   sub register($self, $app, $params) {
  0            
  0            
  0            
  0            
256             # Prefix handling
257 0   0       my $prefix = $params->{prefix} // $DEFAULT_PREFIX;
258 0 0         $prefix .= '.' if ($prefix);
259 0           my $params_helper = "__oidc_params";
260 0           my $token_helper = "__oidc_token";
261 0           my $current_user_helper = "${prefix}current_user";
262 0           my $current_user_roles_helper = "${prefix}current_user_roles";
263             # Parameter handling
264 0           my %conf = (%DEFAULT_CONSTANTS, %DEFAULT_PARAMS, client_id => lc($app->moniker));
265 0           $conf{$_} = $params->{$_} foreach (grep {exists($params->{$_})} (keys(%DEFAULT_PARAMS), @REQUIRED_PARAMS, @ALLOWED_PARAMS));
  0            
266             # die if required/conditionally required params aren't found
267 0 0         foreach (@REQUIRED_PARAMS) {die("Required param '$_' not found") unless (defined($conf{$_}))}
  0            
268 0 0 0       die("Required param 'redirect_path' not found") if ($conf{make_routes} && !defined($conf{redirect_path}));
269              
270             # wrap success handler so that we can call login handler before finishing the req
271 0           my $success_handler = $conf{on_success};
272 0     0     $conf{on_success} = sub($c, $token, $url) {
  0            
  0            
  0            
  0            
273 0           my $token_data = $c->app->renderer->get_helper($token_helper)->($c, $token);
274 0           my $user = $conf{get_user}->($token_data);
275 0 0         $conf{on_login}->($c, $user) if ($conf{on_login});
276 0           return $success_handler->($c, $token, $url);
277 0           };
278              
279             # Add our controller to the namespace for calling via routes or, e.g., OpenAPI
280 0           push($app->routes->namespaces->@*, 'Mojolicious::Plugin::Authentication::OIDC::Controller');
281              
282             # Fetch actual endpoints from well-known URL
283 0           my $resp = Mojo::UserAgent->new()->get($conf{well_known_url});
284 0 0         die("Unable to determine OIDC endpoints (" . $resp->res->error->{message} . ")\n") if ($resp->res->is_error);
285             @conf{qw(auth_endpoint token_endpoint logout_endpoint)} =
286 0           @{$resp->res->json}{qw(authorization_endpoint token_endpoint end_session_endpoint)};
  0            
287              
288             # internal helper for stored parameters (only to be used by OpenIDConnect controller)
289             $app->helper(
290             $params_helper => sub {
291 0     0     return {map {$_ => $conf{$_}}
  0            
292             qw(auth_endpoint scope response_type login_path token_endpoint client_id client_secret grant_type on_error on_success logout_endpoint base_url)
293             };
294             }
295 0           );
296              
297             # internal helper for decoded auth token. Pass the token in, or it'll be retrieved
298             # via `get_token` handler
299 0           $app->helper(
300 0     0     $token_helper => sub($c, $token = undef, $decode = 1) {
  0            
  0            
  0            
301 0   0       my $t = $token // $conf{get_token}->($c);
302 0 0         return $t unless ($decode);
303 0 0 0       return undef if (!defined($t) || $t eq 'null');
304 0           return decode_jwt(token => $t, key => \$conf{public_key});
305             }
306 0           );
307              
308             # public helper to access current user and OIDC roles
309 0           $app->helper(
310 0     0     $current_user_helper => sub($c) {
  0            
311 0           my $t = $c->app->renderer->get_helper($token_helper)->($c);
312 0 0 0       return undef if (!defined($t) || $t eq 'null');
313 0           return $conf{get_user}->($t);
314             }
315 0           );
316 0           $app->helper(
317 0     0     $current_user_roles_helper => sub($c) {
  0            
318 0           my ($user, $token);
319             try {
320             $token = $c->app->renderer->get_helper($token_helper)->($c);
321             return [] unless ($token);
322             $user = $c->app->renderer->get_helper($current_user_helper)->($c);
323             my @roles = $conf{get_roles}->($user, $token)->@*;
324             @roles = grep {defined} map {$conf{role_map}->{$_}} @roles if (defined($conf{role_map}));
325             return [@roles];
326 0           } catch ($e) {
327             return undef
328             }
329 0           return undef;
330             }
331 0           );
332              
333             # if `on_activity` handler exists, call it from a before_dispatch hook
334 0           $app->hook(
335 0     0     before_dispatch => sub($c) {
  0            
336 0           my $u;
337 0           try {$u = $c->app->renderer->get_helper($current_user_helper)->($c);} catch ($e) {
338             }
339 0 0         $conf{on_activity}->($c, $u) if ($u);
340             }
341             )
342 0 0         if ($conf{on_activity});
343             # if `make_routes` is true, register our controller actions at the appropriate paths
344             # otherwise, it's up to the downstream code to do this, e.g., via OpenAPI spec
345 0 0         if ($conf{make_routes}) {
346 0           $app->routes->get($conf{redirect_path})->to("OpenIDConnect#redirect");
347 0           $app->routes->get($conf{login_path})->to('OpenIDConnect#login');
348             }
349             }
350              
351             =head1 AUTHOR
352              
353             Mark Tyrrell C<< >>
354              
355             =head1 LICENSE
356              
357             Copyright (c) 2024 Mark Tyrrell
358              
359             Permission is hereby granted, free of charge, to any person obtaining a copy
360             of this software and associated documentation files (the "Software"), to deal
361             in the Software without restriction, including without limitation the rights
362             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
363             copies of the Software, and to permit persons to whom the Software is
364             furnished to do so, subject to the following conditions:
365              
366             The above copyright notice and this permission notice shall be included in all
367             copies or substantial portions of the Software.
368              
369             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
370             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
371             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
372             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
373             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
374             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
375             SOFTWARE.
376              
377             =cut
378              
379             1;
380              
381             __END__