File Coverage

blib/lib/Plack/Auth/SSO/OIDC.pm
Criterion Covered Total %
statement 45 180 25.0
branch 0 46 0.0
condition 0 5 0.0
subroutine 15 29 51.7
pod 0 7 0.0
total 60 267 22.4


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