File Coverage

blib/lib/Plack/Auth/SSO/OIDC.pm
Criterion Covered Total %
statement 45 189 23.8
branch 0 48 0.0
condition 0 5 0.0
subroutine 15 29 51.7
pod 1 7 14.2
total 61 278 21.9


line stmt bran cond sub pod time code
1             package Plack::Auth::SSO::OIDC;
2              
3 1     1   101378 use strict;
  1         2  
  1         27  
4 1     1   4 use warnings;
  1         5  
  1         48  
5 1     1   4 use feature qw(:5.10);
  1         2  
  1         132  
6 1     1   396 use Data::Util qw(:check);
  1         912  
  1         236  
7 1     1   357 use Data::UUID;
  1         601  
  1         56  
8 1     1   1733 use Moo;
  1         6852  
  1         5  
9 1     1   2094 use Plack::Request;
  1         95487  
  1         50  
10 1     1   609 use Plack::Session;
  1         1674  
  1         81  
11 1     1   9 use URI;
  1         2  
  1         29  
12 1     1   870 use LWP::UserAgent;
  1         62436  
  1         53  
13 1     1   966 use JSON;
  1         12428  
  1         8  
14 1     1   1255 use Crypt::JWT;
  1         66723  
  1         85  
15 1     1   812 use MIME::Base64;
  1         994  
  1         85  
16 1     1   760 use Digest::SHA;
  1         4538  
  1         77  
17 1     1   14 use Try::Tiny;
  1         2  
  1         4586  
18              
19             our $VERSION = "0.015";
20              
21             with "Plack::Auth::SSO";
22              
23             has scope => (
24             is => "lazy",
25             isa => sub {
26             is_string($_[0]) or die("scope should be string");
27             index($_[0], "openid") >= 0 or die("default scope openid not included");
28             },
29             default => sub { "openid profile email" },
30             required => 1
31             );
32              
33             has client_id => (
34             is => "ro",
35             isa => sub { is_string($_[0]) or die("client_id should be string"); },
36             required => 1
37             );
38              
39             has client_secret => (
40             is => "ro",
41             isa => sub { is_string($_[0]) or die("client_secret should be string"); },
42             required => 1
43             );
44              
45             has openid_uri => (
46             is => "ro",
47             isa => sub { is_string($_[0]) or die("openid_uri should be string"); },
48             required => 1
49             );
50              
51             has uid_key => (
52             is => "ro",
53             isa => sub { is_string($_[0]) or die("uid_key should be string"); },
54             required => 1
55             );
56              
57             has authorize_params => (
58             is => "ro",
59             isa => sub { is_hash_ref($_[0]) or die("authorize_params should be hash reference"); },
60             lazy => 1,
61             default => sub { +{}; },
62             required => 1
63             );
64              
65             has allowed_authorize_params => (
66             is => "ro",
67             isa => sub { is_array_ref($_[0]) or die("allowed_authorize_params should be array reference"); },
68             lazy => 1,
69             default => sub { []; },
70             required => 1
71             );
72              
73             has store_oidc_response => (
74             is => "ro",
75             default => sub { 1; },
76             );
77              
78              
79             # internal (non overwritable) moo attributes
80             has json => (
81             is => "ro",
82             lazy => 1,
83             default => sub {
84             JSON->new->utf8(1);
85             },
86             init_arg => undef
87             );
88              
89             has ua => (
90             is => "ro",
91             lazy => 1,
92             default => sub {
93             LWP::UserAgent->new();
94             },
95             init_arg => undef
96             );
97              
98             has openid_configuration => (
99             is => "lazy",
100             init_arg => undef
101             );
102              
103             has jwks => (
104             is => "lazy",
105             init_arg => undef
106             );
107              
108             sub get_json {
109              
110 0     0 0   my ($self, $url) = @_;
111              
112 0           my $res = $self->ua->get($url);
113              
114 0 0         if ( $res->code ne "200" ) {
115              
116 0           $self->log->errorf("url $url returned invalid status code %s", $res->code);
117 0           return undef, "INVALID_HTTP_STATUS";
118              
119             }
120              
121 0 0         if ( index($res->content_type, "json") < 0 ) {
122              
123 0           $self->log->errorf("url $url returned invalid content type %s", $res->content_type);
124 0           return undef, "INVALID_HTTP_CONTENT_TYPE";
125              
126             }
127              
128 0           my $data;
129             my $data_error;
130             try {
131 0     0     $data = $self->json->decode($res->content);
132             } catch {
133 0     0     $data_error = $_;
134 0           };
135              
136 0 0         if ( defined($data_error) ) {
137              
138 0           $self->log->error("could not decode json returned from $url");
139 0           return undef, "INVALID_HTTP_CONTENT";
140              
141             }
142              
143 0           $data;
144              
145             }
146              
147             sub _build_openid_configuration {
148              
149 0     0     my $self = $_[0];
150              
151 0           my $url = $self->openid_uri;
152 0           my ($data, @errors) = $self->get_json($url);
153 0 0         die("unable to retrieve openid configuration from $url: ".join(", ",@errors))
154             unless defined($data);
155 0           $data;
156              
157             }
158              
159             # https://auth0.com/blog/navigating-rs256-and-jwks/
160             sub _build_jwks {
161              
162 0     0     my $self = $_[0];
163              
164 0           my $jwks_uri = $self->openid_configuration->{jwks_uri};
165              
166 0 0         die("attribute jwks_uri not found in openid_configuration")
167             unless is_string($jwks_uri);
168              
169 0           my ($data, @errors) = $self->get_json($jwks_uri);
170              
171 0 0         die("unable to retrieve jwks from ".$self->openid_configuration->{jwks_uri}.":".join(", ", @errors))
172             if scalar(@errors);
173              
174 0           $data;
175              
176             }
177              
178             sub redirect_uri {
179              
180 0     0 1   my ($self, $request) = @_;
181 0           my $redirect_uri = $self->uri_base().$request->request_uri();
182 0           my $idx = index( $redirect_uri, "?" );
183 0 0         if ( $idx >= 0 ) {
184              
185 0           $redirect_uri = substr( $redirect_uri, 0, $idx );
186              
187             }
188 0           $redirect_uri;
189              
190             }
191              
192             sub make_random_string {
193              
194 0     0 0   MIME::Base64::encode_base64url(
195             Data::UUID->new->create() .
196             Data::UUID->new->create() .
197             Data::UUID->new->create()
198             );
199              
200             }
201              
202             sub generate_authorization_uri {
203              
204 0     0 0   my ($self, %args) = @_;
205              
206 0           my $request = $args{request};
207 0           my $session = $args{session};
208 0           my $query_params = $request->query_parameters();
209              
210 0           my $openid_conf = $self->openid_configuration;
211 0           my $authorization_endpoint = $openid_conf->{authorization_endpoint};
212              
213             # cf. https://developers.onelogin.com/openid-connect/guides/auth-flow-pkce
214             # Note: minimum of 43 characters!
215 0           my $code_verifier = $self->make_random_string();
216 0           my $code_challenge = MIME::Base64::encode_base64url(Digest::SHA::sha256($code_verifier),"");
217 0           my $state = $self->make_random_string();
218              
219 0           my %query;
220              
221             # merge in allowed params from current url
222 0           for my $key( @{ $self->allowed_authorize_params } ){
  0            
223              
224 0           my $val = $query_params->get($key);
225              
226 0 0         next unless is_string($val);
227              
228 0           $query{$key} = $val;
229              
230             }
231              
232             %query = (
233             %query,
234 0           %{ $self->authorize_params },
  0            
235             code_challenge => $code_challenge,
236             code_challenge_method => "S256",
237             state => $state,
238             scope => $self->scope(),
239             client_id => $self->client_id,
240             response_type => "code",
241             redirect_uri => $self->redirect_uri($request)
242             );
243              
244 0           my $uri = URI->new($authorization_endpoint);
245 0           $uri->query_form(%query);
246 0           $self->set_csrf_token($session, $state);
247 0           $session->set("auth_sso_oidc_code_verifier", $code_verifier);
248              
249 0           $uri->as_string;
250              
251             }
252              
253             around cleanup => sub {
254              
255             my ($orig, $self, $session) = @_;
256             $self->$orig($session);
257             $session->remove("auth_sso_oidc_code_verifier");
258              
259             };
260              
261             # extract_claims_from_id_token(id_token) : claims_as_hash
262             sub extract_claims_from_id_token {
263              
264 0     0 0   my ($self, $id_token) = @_;
265              
266 0           my ($jose_header, $payload, $s) = split(/\./o, $id_token);
267              
268             # '{ "alg": "RS256", "kid": "my-key-id" }'
269 0           $jose_header = $self->json->decode(MIME::Base64::decode($jose_header));
270              
271             #{ "keys": [{ "kid": "my-key-id", "alg": "RS256", "use": "sig" .. }] }
272 0           my $jwks = $self->jwks();
273 0           my ($key) = grep { $_->{kid} eq $jose_header->{kid} }
274 0           @{ $jwks->{keys} };
  0            
275              
276 0           my $claims;
277             my $claims_error;
278             try {
279 0     0     $claims = Crypt::JWT::decode_jwt(token => $id_token, key => $key);
280             } catch {
281 0     0     $claims_error = $_;
282 0           };
283              
284 0 0         $self->log->errorf("error occurred while decoding JWS: %s", $claims_error)
285             if defined $claims_error;
286              
287 0           $claims;
288              
289             }
290              
291             sub exchange_code_for_tokens {
292              
293 0     0 0   my ($self, %args) = @_;
294              
295 0           my $request = $args{request};
296 0           my $session = $args{session};
297 0           my $code = $args{code};
298              
299 0           my $openid_conf = $self->openid_configuration;
300 0           my $token_endpoint = $openid_conf->{token_endpoint};
301 0   0       my $token_endpoint_auth_methods_supported = $openid_conf->{token_endpoint_auth_methods_supported} // [];
302 0 0         $token_endpoint_auth_methods_supported =
303             is_array_ref($token_endpoint_auth_methods_supported) ?
304             $token_endpoint_auth_methods_supported :
305             [$token_endpoint_auth_methods_supported];
306              
307 0           my $auth_sso_oidc_code_verifier = $session->get("auth_sso_oidc_code_verifier");
308              
309 0           my $params = {
310             grant_type => "authorization_code",
311             client_id => $self->client_id,
312             code => $code,
313             code_verifier => $auth_sso_oidc_code_verifier,
314             redirect_uri => $self->redirect_uri($request),
315             };
316              
317 0           my $headers = {
318             "Content-Type" => "application/x-www-form-urlencoded"
319             };
320 0           my $client_id = $self->client_id;
321 0           my $client_secret = $self->client_secret;
322              
323 0 0         if ( grep { $_ eq "client_secret_basic" } @$token_endpoint_auth_methods_supported ) {
  0 0          
324              
325 0           $self->log->info("using client_secret_basic");
326 0           $headers->{"Authorization"} = "Basic " . MIME::Base64::encode("$client_id:$client_secret", "");
327              
328             }
329 0           elsif ( grep { $_ eq "client_secret_post" } @$token_endpoint_auth_methods_supported ) {
330              
331 0           $self->log->info("using client_secret_post");
332 0           $params->{client_secret} = $client_secret;
333              
334             }
335             else {
336              
337 0           die("token_endpoint $token_endpoint does not support client_secret_basic or client_secret_post");
338              
339             }
340              
341 0           my $res = $self->ua->post(
342             $token_endpoint,
343             $params,
344             %$headers
345             );
346              
347 0 0         die("$token_endpoint returned invalid content type ".$res->content_type)
348             unless $res->content_type =~ /json/o;
349              
350 0           $self->json->decode($res->content);
351             }
352              
353             sub to_app {
354              
355 0     0 0   my $self = $_[0];
356              
357             sub {
358              
359 0     0     my $env = $_[0];
360 0           my $log = $self->log();
361              
362 0           my $request = Plack::Request->new($env);
363 0           my $session = Plack::Session->new($env);
364 0           my $query_params = $request->query_parameters();
365              
366 0 0         if( $self->log->is_debug() ){
367              
368 0           $self->log->debugf( "incoming query parameters: %s", [$query_params->flatten] );
369 0           $self->log->debugf( "session: %s", $session->dump() );
370 0           $self->log->debugf( "session_key for auth_sso: %s" . $self->session_key() );
371              
372             }
373              
374 0 0         if ( $request->method ne "GET" ) {
375              
376 0           $self->log->errorf("invalid http method %s", $request->method);
377 0           return [400, [ "Content-Type" => "text/plain" ], ["invalid http method"]];
378              
379             }
380              
381 0           my $state = $query_params->get("state");
382 0           my $stored_state = $self->get_csrf_token($session);
383              
384             # remove auth_sso from possibly previous successfull authentication
385             # (allowing for reauthentication)
386 0           $session->remove($self->session_key);
387              
388             # redirect to authorization url
389 0 0 0       if ( !(is_string($stored_state) && is_string($state)) ) {
390              
391 0           $self->cleanup($session);
392              
393 0           my $authorization_uri = $self->generate_authorization_uri(
394             request => $request,
395             session => $session
396             );
397              
398 0           return [302, [Location => $authorization_uri], []];
399              
400             }
401              
402             # check csrf
403 0 0         if ( $stored_state ne $state ) {
404              
405 0           $self->cleanup($session);
406 0           $self->set_auth_sso_error( $session,{
407             package => __PACKAGE__,
408             package_id => $self->id,
409             type => "CSRF_DETECTED",
410             content => "CSRF_DETECTED"
411             });
412 0           return $self->redirect_to_error();
413              
414             }
415              
416             # validate authorization returned from idp
417 0           my $error = $query_params->get("error");
418 0           my $error_description = $query_params->get("error_description");
419              
420 0 0         if ( is_string($error) ) {
421              
422 0           $self->cleanup($session);
423 0           $self->set_auth_sso_error($session, {
424             package => __PACKAGE__,
425             package_id => $self->id,
426             type => $error,
427             content => $error_description
428             });
429 0           return $self->redirect_to_error();
430              
431             }
432              
433 0           my $code = $query_params->get("code");
434              
435 0 0         unless ( is_string($code) ) {
436              
437 0           $self->cleanup($session);
438 0           $self->set_auth_sso_error($session, {
439             package => __PACKAGE__,
440             package_id => $self->id,
441             type => "AUTH_SSO_OIDC_AUTHORIZATION_NO_CODE",
442             content => "oidc authorization endpoint did not return query parameter code"
443             });
444 0           return $self->redirect_to_error();
445              
446             }
447              
448 0           my $tokens = $self->exchange_code_for_tokens(
449             request => $request,
450             session => $session,
451             code => $code
452             );
453              
454 0 0         $self->log->debugf("tokens: %s", $tokens)
455             if $self->log->is_debug();
456              
457 0 0         if ( is_string($tokens->{error}) ) {
458              
459 0           $self->cleanup($session);
460             $self->set_auth_sso_error($session, {
461             package => __PACKAGE__,
462             package_id => $self->id,
463             type => $tokens->{error},
464             content => $tokens->{error_description}
465 0           });
466 0           return $self->redirect_to_error();
467              
468             }
469              
470 0           my $claims = $self->extract_claims_from_id_token($tokens->{id_token});
471              
472 0 0         $self->log->debugf("claims: %s", $claims)
473             if $self->log->is_debug();
474              
475 0           $self->cleanup($session);
476              
477             my $session_data = +{
478             extra => {},
479             info => $claims,
480 0           uid => $claims->{ $self->uid_key() },
481             package => __PACKAGE__,
482             package_id => $self->id,
483             };
484              
485 0 0         if ($self->store_oidc_response()) {
486             $session_data->{response} = +{
487 0           content => $self->json->encode($tokens),
488             content_type => "application/json"
489             };
490             }
491              
492 0           $self->set_auth_sso($session, $session_data);
493              
494 0 0         $self->log->debugf("auth_sso: %s", $self->get_auth_sso($session))
495             if $self->log->is_debug();
496              
497 0           return $self->redirect_to_authorization();
498              
499 0           };
500              
501             }
502              
503             1;
504              
505             =pod
506              
507             =head1 NAME
508              
509             Plack::Auth::SSO::OIDC - implementation of OpenID Connect for Plack::Auth::SSO
510              
511             =begin markdown
512              
513             # STATUS
514              
515             [![Build Status](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC.svg?branch=main)](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC)
516             [![Coverage](https://coveralls.io/repos/LibreCat/Plack-Auth-SSO-OIDC/badge.png?branch=main)](https://coveralls.io/r/LibreCat/Plack-Auth-SSO-OIDC)
517             [![CPANTS kwalitee](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC.png)](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC)
518              
519             =end markdown
520              
521             =head1 DESCRIPTION
522              
523             This is an implementation of L to authenticate against a openid connect server.
524              
525             It inherits all configuration options from its parent.
526              
527             =head1 SYNOPSIS
528              
529             # in your app.psi (Plack)
530              
531             use strict;
532             use warnings;
533             use Plack::Builder;
534             use JSON;
535             use Plack::Auth::SSO::OIDC;
536             use Plack::Session::Store::File;
537              
538             my $uri_base = "http://localhost:5000";
539              
540             builder {
541              
542             # session middleware needed to store "auth_sso" and/or "auth_sso_error"
543             # in memory session store for testing purposes
544             enable "Session";
545              
546             # for authentication, redirect your users to this path
547             mount "/auth/oidc" => Plack::Auth::SSO::OIDC->new(
548              
549             # plack application needs to know about the base url of this application
550             uri_base => $uri_base,
551              
552             # after successfull authentication, user is redirected to this path (uri_base is used!)
553             authorization_path => "/auth/callback",
554              
555             # when authentication fails at the identity provider
556             # user is redirected to this path with session key "auth_sso_error" (hash)
557             error_path => "/auth/error",
558              
559             # openid connect discovery url
560             openid_uri => "https://example.oidc.org/auth/oidc/.well-known/openid-configuration",
561             client_id => "my-client-id",
562             client_secret => "myclient-secret",
563             uid_key => "email"
564              
565             )->to_app();
566              
567             # example psgi app that is called after successfull authentication at /auth/oidc (see above)
568             # it expects session key "auth_sso" to be present
569             # here you typically create a user session based on the uid in "auth_sso"
570             mount "/auth/callback" => sub {
571              
572             my $env = shift;
573             my $session = Plack::Session->new($env);
574             my $auth_sso= $session->get("auth_sso");
575             my $user = MyUsers->get( $auth_sso->{uid} );
576             $session->set("user_id", $user->{id});
577             [ 200, [ "Content-Type" => "text/plain" ], [
578             "logged in! ", $user->{name}
579             ]];
580              
581             };
582              
583             # example psgi app that is called after unsuccessfull authentication at /auth/oidc (see above)
584             # it expects session key "auth_sso_error" to be present
585             mount "/auth/error" => sub {
586              
587             my $env = shift;
588             my $session = Plack::Session->new($env);
589             my $auth_sso_error = $session->get("auth_sso_error");
590              
591             [ 200, [ "Content-Type" => "text/plain" ], [
592             "something happened during single sign on authentication: ",
593             $auth_sso_error->{content}
594             ]];
595              
596             };
597             };
598              
599             =head1 CONSTRUCTOR ARGUMENTS
600              
601             =over 4
602              
603             =item C<< uri_base >>
604              
605             See L
606              
607             =item C<< id >>
608              
609             See L
610              
611             =item C<< session_key >>
612              
613             See L
614              
615             =item C<< authorization_path >>
616              
617             See L
618              
619             =item C<< error_path >>
620              
621             See L
622              
623             =item C<< openid_uri >>
624              
625             base url of the OIDC discovery url.
626              
627             typically an url that ends on C<< /.well-known/openid-configuration >>
628              
629             =item C<< client_id >>
630              
631             client-id as given by the OIDC service
632              
633             =item C<< client_secret >>
634              
635             client-secret as given by the OIDC service
636              
637             =item C<< scope >>
638              
639             Scope requested from the OIDC service.
640              
641             Space separated string containing all scopes
642              
643             Default: C<< "openid profile email" >>
644              
645             Please include scope C<< "openid" >>
646              
647             cf. L
648              
649             =item C<< authorize_params >>
650              
651             Hash reference of parameters (values must be strings) that are added to
652              
653             the authorization url. Empty by default
654              
655             e.g. C<< { prompt => "login", "kc_idp_hint" => "orcid" } >>
656              
657             Note that some parameters are set internally
658              
659             and therefore will have no effect:
660              
661             =over 6
662              
663             =item C<< code_challenge >>
664              
665             =item C<< code_challenge_method >>
666              
667             =item C<< state >>
668              
669             =item C<< scope >>
670              
671             =item C<< client_id >>
672              
673             =item C<< response_type >>
674              
675             =item C<< redirect_uri >>
676              
677             =back
678              
679             =item C<< allowed_authorize_params >>
680              
681             Array reference of parameter names.
682              
683             When constructing the authorization url,
684              
685             these parameters are copied from the current url query
686              
687             to the authorization url. This allows to add some
688              
689             dynamic configuration, but should be used with caution.
690              
691             Note that parameters from C<< authorize_params >> always
692              
693             take precedence.
694              
695             =item C<< uid_key >>
696              
697             Attribute from claims to be used as uid
698              
699             Note that all claims are also stored in C<< $session->get("auth_sso")->{info} >>
700              
701             =item C<< store_oidc_response >>
702              
703             Store C<< content >> and C<< content_type >> of returned OIDC response in session key `auth_sso.response.content` and `auth_sso.response.content_type`
704             respectively. This can exhaust the cookie length if all session data is stored in the cookie.
705              
706             Default: C<< 1 >>
707              
708             =back
709              
710             =head1 HOW IT WORKS
711              
712             =over 4
713              
714             =item the openid configuration is retrieved from C<< {openid_uri} >>
715              
716             =over 6
717              
718             =item key C<< authorization_endpoint >> must be present in openid configuration
719              
720             =item key C<< token_endpoint >> must be present in openid configuration
721              
722             =item key C<< jwks_uri >> must be present in openid configuration
723              
724             =item the user is redirected to the authorization endpoint with extra query parameters
725              
726             =back
727              
728             =item after authentication at the authorization endpoint, the user is redirected back to this url with query parameters C<< code >> and C<< state >>. When something happened at the authorization endpoint, query parameters C<< error >> and C<< error_description >> are returned, and no C<< code >>.
729              
730             =item C<< code >> is exchanged for a json string, using the token endpoint. This json string is a record that contains attributes like C<< id_token >> and C<< access_token >>. See L for more information.
731              
732             =item key C<< id_token >> in the token json string contains three parts:
733              
734             =over 6
735              
736             =item jwt jose header. Can be decoded with base64 into a json string
737              
738             =item jwt payload. Can be decoded with base64 into a json string
739              
740             =item jwt signature
741              
742             =back
743              
744             =item the jwt payload from the C<< id_token >> is decoded into a json string and then to a perl hash. All this data is stored C<< $session->{auth_sso}->{info} >>. One of these attributes will be the uid that will be stored at C<< $session->{auth_sso}->{uid} >>. This is determined by configuration key C<< uid_key >> (see above). e.g. "email"
745              
746             =back
747              
748             =head1 NOTES
749              
750             =over 4
751              
752             =item Can I reauthenticate when I visit the application?
753              
754             When this Plack application is for example mounted at
755              
756             C<< /auth/oidc >>, then you can reauthenticate by visiting
757              
758             it again, but it depends on your configuration what actually
759              
760             happens at the openid connect server. If C<< prompt >> is not
761              
762             set anywhere (neither in C<< authorize_params >> nor in the
763              
764             current url if that is allowed), then the external server
765              
766             will just sent you back with the same tokens.
767              
768             Note that C<< session("auth_sso") >> is removed at the start
769              
770             of every (re)authentication.
771              
772             =back
773              
774             =head1 LOGGING
775              
776             All subclasses of L use L
777             to log messages to the category that equals the current
778             package name.
779              
780             =head1 AUTHOR
781              
782             Nicolas Franck, C<< >>
783              
784             =head1 LICENSE AND COPYRIGHT
785              
786             This program is free software; you can redistribute it and/or modify it
787             under the terms of either: the GNU General Public License as published
788             by the Free Software Foundation; or the Artistic License.
789              
790             See L for more information.
791              
792             =head1 SEE ALSO
793              
794             L
795              
796             =cut