File Coverage

blib/lib/Plack/Auth/SSO/OIDC.pm
Criterion Covered Total %
statement 45 186 24.1
branch 0 46 0.0
condition 0 5 0.0
subroutine 15 29 51.7
pod 1 7 14.2
total 61 273 22.3


line stmt bran cond sub pod time code
1             package Plack::Auth::SSO::OIDC;
2:

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