File Coverage

blib/lib/Plack/Auth/SSO/OIDC.pm
Criterion Covered Total %
statement 45 176 25.5
branch 0 44 0.0
condition 0 5 0.0
subroutine 15 29 51.7
pod 0 7 0.0
total 60 261 22.9


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.01";
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_base64url("$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();
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();
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();
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: my $claims = $self->extract_claims_from_id_token($tokens->{id_token});
425:
426: $self->log->debugf("claims: %s", $claims)
427: if $self->log->is_debug();
428:
429: $self->cleanup($session);
430:
431: $self->set_auth_sso(
432: $session,
433: {
434: extra => {},
435: info => $claims,
436: uid => $claims->{ $self->uid_key() },
437: package => __PACKAGE__,
438: package_id => $self->id,
439: response => {
440: content => $self->json->encode($tokens),
441: content_type => "application/json"
442: }
443: }
444: );
445:
446: $self->log->debugf("auth_sso: %s", $self->get_auth_sso($session))
447: if $self->log->is_debug();
448:
449: return $self->redirect_to_authorization();
450:
451: };
452:
453: }
454:
455: 1;
456:
457: =pod
458:
459: =head1 NAME
460:
461: Plack::Auth::SSO::OIDC - implementation of OpenID Connect for Plack::Auth::SSO
462:
463: =begin markdown
464:
465: # STATUS
466:
467: [![Build Status](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC.svg?branch=main)](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC)
468: [![Coverage](https://coveralls.io/repos/LibreCat/Plack-Auth-SSO-OIDC/badge.png?branch=main)](https://coveralls.io/r/LibreCat/Plack-Auth-SSO-OIDC)
469: [![CPANTS kwalitee](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC.png)](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC)
470:
471: =end markdown
472:
473: =head1 DESCRIPTION
474:
475: This is an implementation of L<Plack::Auth::SSO> to authenticate against a openid connect server.
476:
477: It inherits all configuration options from its parent.
478:
479: =head1 SYNOPSIS
480:
481: # in your app.psi (Plack)
482:
483: use strict;
484: use warnings;
485: use Plack::Builder;
486: use JSON;
487: use Plack::Auth::SSO::OIDC;
488: use Plack::Session::Store::File;
489:
490: my $uri_base = "http://localhost:5000";
491:
492: builder {
493:
494: # session middleware needed to store "auth_sso" and/or "auth_sso_error"
495: # in memory session store for testing purposes
496: enable "Session";
497:
498: # for authentication, redirect your users to this path
499: mount "/auth/oidc" => Plack::Auth::SSO::OIDC->new(
500:
501: # plack application needs to know about the base url of this application
502: uri_base => $uri_base,
503:
504: # after successfull authentication, user is redirected to this path (uri_base is used!)
505: authorization_path => "/auth/callback",
506:
507: # when authentication fails at the identity provider
508: # user is redirected to this path with session key "auth_sso_error" (hash)
509: error_path => "/auth/error",
510:
511: # openid connect discovery url
512: openid_uri => "https://example.oidc.org/auth/oidc/.well-known/openid-configuration",
513: client_id => "my-client-id",
514: client_secret => "myclient-secret",
515: uid_key => "email"
516:
517: )->to_app();
518:
519: # example psgi app that is called after successfull authentication at /auth/oidc (see above)
520: # it expects session key "auth_sso" to be present
521: # here you typically create a user session based on the uid in "auth_sso"
522: mount "/auth/callback" => sub {
523:
524: my $env = shift;
525: my $session = Plack::Session->new($env);
526: my $auth_sso= $session->get("auth_sso");
527: my $user = MyUsers->get( $auth_sso->{uid} );
528: $session->set("user_id", $user->{id});
529: [ 200, [ "Content-Type" => "text/plain" ], [
530: "logged in! ", $user->{name}
531: ]];
532:
533: };
534:
535: # example psgi app that is called after unsuccessfull authentication at /auth/oidc (see above)
536: # it expects session key "auth_sso_error" to be present
537: mount "/auth/error" => sub {
538:
539: my $env = shift;
540: my $session = Plack::Session->new($env);
541: my $auth_sso_error = $session->get("auth_sso_error");
542:
543: [ 200, [ "Content-Type" => "text/plain" ], [
544: "something happened during single sign on authentication: ",
545: $auth_sso_error->{content}
546: ]];
547:
548: };
549: };
550:
551: =head1 CONSTRUCTOR ARGUMENTS
552:
553: =over 4
554:
555: =item C<< uri_base >>
556:
557: See L<Plack::Auth::SSO/uri_base>
558:
559: =item C<< id >>
560:
561: See L<Plack::Auth::SSO/id>
562:
563: =item C<< session_key >>
564:
565: See L<Plack::Auth::SSO/session_key>
566:
567: =item C<< authorization_path >>
568:
569: See L<Plack::Auth::SSO/authorization_path>
570:
571: =item C<< error_path >>
572:
573: See L<Plack::Auth::SSO/error_path>
574:
575: =item C<< openid_uri >>
576:
577: base url of the OIDC discovery url.
578:
579: typically an url that ends on C<< /.well-known/openid-configuration >>
580:
581: =item C<< client_id >>
582:
583: client-id as given by the OIDC service
584:
585: =item C<< client_secret >>
586:
587: client-secret as given by the OIDC service
588:
589: =item C<< scope >>
590:
591: Scope requested from the OIDC service.
592:
593: Space separated string containing all scopes
594:
595: Default: C<< "openid profile email" >>
596:
597: Please include scope C<< "openid" >>
598:
599: cf. L<https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
600:
601: =item C<< uid_key >>
602:
603: Attribute from claims to be used as uid
604:
605: Note that all claims are also stored in C<< $session->get("auth_sso")->{info} >>
606:
607: =back
608:
609: =head1 HOW IT WORKS
610:
611: =over 4
612:
613: =item the openid configuration is retrieved from C<< {openid_uri} >>
614:
615: =over 6
616:
617: =item key C<< authorization_endpoint >> must be present in openid configuration
618:
619: =item key C<< token_endpoint >> must be present in openid configuration
620:
621: =item key C<< jwks_uri >> must be present in openid configuration
622:
623: =item the user is redirected to the authorization endpoint with extra query parameters
624:
625: =back
626:
627: =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 >>.
628:
629: =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.
630:
631: =item key C<< id_token >> in the token json string contains three parts:
632:
633: =over 6
634:
635: =item jwt jose header. Can be decoded with base64 into a json string
636:
637: =item jwt payload. Can be decoded with base64 into a json string
638:
639: =item jwt signature
640:
641: =back
642:
643: =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"
644:
645: =back
646:
647: =head1 LOGGING
648:
649: All subclasses of L<Plack::Auth::SSO> use L<Log::Any>
650: to log messages to the category that equals the current
651: package name.
652:
653: =head1 AUTHOR
654:
655: Nicolas Franck, C<< <nicolas.franck at ugent.be> >>
656:
657: =head1 LICENSE AND COPYRIGHT
658:
659: This program is free software; you can redistribute it and/or modify it
660: under the terms of either: the GNU General Public License as published
661: by the Free Software Foundation; or the Artistic License.
662:
663: See L<http://dev.perl.org/licenses/> for more information.
664:
665: =head1 SEE ALSO
666:
667: L<Plack::Auth::SSO>
668:
669: =cut
670: