File Coverage

blib/lib/Web/Solid/Auth.pm
Criterion Covered Total %
statement 54 267 20.2
branch 0 68 0.0
condition 0 15 0.0
subroutine 18 44 40.9
pod 6 22 27.2
total 78 416 18.7


line stmt bran cond sub pod time code
1             package Web::Solid::Auth;
2              
3 1     1   112755 use Moo;
  1         13094  
  1         8  
4 1     1   5675 use Crypt::JWT;
  1         84185  
  1         49  
5 1     1   636 use Data::Dumper;
  1         6308  
  1         61  
6 1     1   1002 use Data::UUID;
  1         2491  
  1         67  
7 1     1   1462 use Digest::SHA;
  1         5002  
  1         63  
8 1     1   1004 use HTTP::Link;
  1         12102  
  1         51  
9 1     1   456 use HTTP::Request;
  1         20733  
  1         32  
10 1     1   501 use HTTP::Server::PSGI;
  1         59098  
  1         32  
11 1     1   455 use Log::Any ();
  1         8365  
  1         23  
12 1     1   738 use LWP::UserAgent;
  1         14204  
  1         33  
13 1     1   8 use JSON;
  1         2  
  1         9  
14 1     1   625 use MIME::Base64;
  1         1396  
  1         65  
15 1     1   2602 use Path::Tiny;
  1         11912  
  1         50  
16 1     1   8 use URI::Escape;
  1         2  
  1         48  
17 1     1   519 use Plack::Request;
  1         55067  
  1         35  
18 1     1   455 use Plack::Response;
  1         1168  
  1         84  
19 1     1   469 use Web::Solid::Auth::Listener;
  1         4  
  1         37  
20 1     1   1177 use Web::Solid::Auth::Util;
  1         4  
  1         3567  
21              
22             our $VERSION = "0.91";
23              
24             has webid => (
25             is => 'ro' ,
26             required => 1
27             );
28             has redirect_uri => (
29             is => 'ro'
30             );
31             has cache => (
32             is => 'ro' ,
33             default => sub { $ENV{HOME} . "/.solid"}
34             );
35             has log => (
36             is => 'ro',
37             default => sub { Log::Any->get_logger },
38             );
39             has agent => (
40             is => 'lazy'
41             );
42             has listener => (
43             is => 'lazy'
44             );
45             has issuer => (
46             is => 'lazy'
47             );
48             has client_id => (
49             is => 'ro',
50             );
51              
52             sub _build_agent {
53 0     0     my $ua = LWP::UserAgent->new(agent => "Web::Solid::Auth/$VERSION");
54 0           $ua;
55             }
56              
57             sub _build_listener {
58 0     0     Web::Solid::Auth::Listener->new;
59             }
60              
61             sub _build_issuer {
62 0     0     shift->get_openid_provider();
63             }
64              
65             sub BUILD {
66 0     0 0   my $self = shift;
67 0   0       $self->{redirect_uri} //= $self->listener->redirect_uri;
68             }
69              
70             sub listen {
71 0     0 1   my $self = shift;
72 0           $self->listener->run($self);
73             }
74              
75             sub has_access_token {
76 0     0 1   my $self = shift;
77 0           my $cache_dir = $self->get_cache_dir;
78 0           my $access = path($cache_dir)->child("access.json");
79 0           $access->exists;
80             }
81              
82             sub make_clean {
83 0     0 1   my $self = shift;
84 0           my $cache_dir = $self->get_cache_dir;
85              
86 0           $self->log->info("cleaning cache directory $cache_dir");
87              
88 0           my $openid = path($cache_dir)->child("openid.json");
89 0 0         $openid->remove if $openid->exists;
90              
91 0           my $client = path($cache_dir)->child("client.json");
92 0 0         $client->remove if $client->exists;
93              
94 0           my $access = path($cache_dir)->child("access.json");
95 0 0         $access->remove if $access->exists;
96              
97 0           $self;
98             }
99              
100             sub make_authorization_request {
101 0     0 1   my $self = shift;
102              
103 0           my $redirect_uri = $self->redirect_uri;
104              
105 0           my $registration_conf = $self->get_client_configuration;
106 0           my $openid_conf = $self->get_openid_configuration;
107              
108 0           my $authorization_endpoint = $openid_conf->{authorization_endpoint};
109 0           my $client_id = $registration_conf->{client_id};
110              
111 0           my $code_verifier = $self->make_random_string;
112 0           my $code_challenge = MIME::Base64::encode_base64url(Digest::SHA::sha256($code_verifier),'');
113 0           $code_challenge =~ s{=}{};
114 0           my $state = $self->make_random_string;
115              
116 0           my $url = $self->make_url(
117             $authorization_endpoint, {
118             code_challenge => $code_challenge ,
119             code_challenge_method => 'S256' ,
120             state => $state ,
121             scope => 'openid profile offline_access' ,
122             client_id => $client_id ,
123             response_type => 'code' ,
124             redirect_uri => $redirect_uri ,
125             });
126              
127 0           $self->{state} = $state;
128 0           $self->{code_verifier} = $code_verifier;
129              
130 0           $self->log->info("generating authorization request: $url");
131              
132 0           return $url;
133             }
134              
135             sub make_access_token {
136 0     0 1   my ($self,$code) = @_;
137              
138 0 0         die "need code" unless $code;
139              
140 0           my $redirect_uri = $self->redirect_uri;
141              
142 0           my $openid_conf = $self->get_openid_configuration;
143 0           my $registration_conf = $self->get_client_configuration;
144              
145 0           my $token_endpoint = $openid_conf->{token_endpoint};
146 0   0       my $token_endpoint_auth_methods_supported = $openid_conf->{token_endpoint_auth_methods_supported} // [];
147              
148             # Make an array out of an string...
149 0 0         $token_endpoint_auth_methods_supported =
150             ref($token_endpoint_auth_methods_supported) eq 'ARRAY' ?
151             $token_endpoint_auth_methods_supported :
152             [$token_endpoint_auth_methods_supported];
153              
154 0           my $client_id = $registration_conf->{client_id};
155 0           my $client_secret = $registration_conf->{client_secret};
156              
157 0           my $dpop_token = $self->make_token_for($token_endpoint,'POST');
158              
159 0           $self->log->info("requesting access token at $token_endpoint");
160              
161             my $token_request = {
162             grant_type => 'authorization_code' ,
163             client_id => $client_id ,
164             redirect_uri => $redirect_uri ,
165             code => $code ,
166             code_verifier => $self->{code_verifier}
167 0           };
168              
169 0           my %headers = (
170             'Content-Type' => 'application/x-www-form-urlencoded' ,
171             DPoP => $dpop_token
172             );
173              
174 0 0         if (grep(/^client_secret_basic/, @$token_endpoint_auth_methods_supported)) {
    0          
175 0           $self->log->info('using client_secret_basic');
176 0           $headers{'Authorization'} = 'Basic ' . MIME::Base64::encode_base64url("$client_id:$client_secret");
177             }
178             elsif (grep(/^client_secret_post/, @$token_endpoint_auth_methods_supported)) {
179 0           $self->log->info('using client_secret_post');
180 0           $token_request->{client_secret} = $client_secret;
181             }
182              
183 0           my $data = $self->post( $token_endpoint, $token_request , %headers );
184              
185 0 0         return undef unless $data;
186              
187 0           $data = decode_json($data);
188              
189 0           $self->log->infof("received: %s", $data);
190              
191 0           my $cache_dir = $self->get_cache_dir;
192 0 0         path($cache_dir)->mkpath unless -d $cache_dir;
193              
194 0           my $cache_file = path($cache_dir)->child("access.json")->stringify;
195 0           path($cache_file)->spew(encode_json($data));
196              
197 0           return $data;
198             }
199              
200             sub make_authentication_headers {
201 0     0 0   my ($self, $uri, $method) = @_;
202              
203 0           my $access = $self->get_access_token;
204              
205 0 0         return undef unless $access;
206              
207             my $headers = {
208             Authorization => 'DPoP ' . $access->{access_token} ,
209 0           DPoP => $self->make_token_for($uri,$method)
210             };
211              
212 0           return $headers;
213             }
214              
215             sub get_cache_dir {
216 0     0 0   my $self = shift;
217 0           my $webid = $self->webid;
218              
219 0 0         die "No webid set" unless $webid;
220              
221 0           my $webid_sha = Digest::SHA::sha1_hex($webid);
222 0           my $cache_dir = sprintf "%s/%s"
223             , $self->cache
224             , Digest::SHA::sha1_hex($webid);
225 0           return $cache_dir;
226             }
227              
228             sub get_access_token {
229 0     0 1   my $self = shift;
230              
231 0           my $cache_dir = $self->get_cache_dir;
232              
233 0 0         return undef unless path($cache_dir)->child("access.json")->exists;
234              
235 0           my $cache_file = path($cache_dir)->child("access.json")->stringify;
236              
237 0           $self->log->debug("reading $cache_file");
238              
239 0           my $json = path("$cache_file")->slurp;
240              
241 0 0         return undef unless $json;
242              
243 0           return decode_json($json);
244             }
245              
246             sub get_openid_provider {
247 0     0 0   my ($self, $webid) = @_;
248 0   0       $webid //= $self->webid;
249              
250 0           my $res = $self->options($webid);
251              
252 0 0         return undef unless $res;
253              
254 0           my $link = $res->header('Link');
255              
256 0           my @links = HTTP::Link->parse($link);
257              
258 0           my $issuer;
259              
260 0           for (@links) {
261 0 0         if ($_->{relation} eq 'http://openid.net/specs/connect/1.0/issuer') {
262 0           $issuer = $_->{iri};
263             }
264             }
265              
266 0 0         if ($issuer) {
267 0           return $issuer;
268             }
269             else {
270             # Try the webid to find the issuer
271 0           return $self->get_webid_openid_provider($webid);
272             }
273             }
274              
275             sub get_webid_openid_provider {
276 0     0 0   my ($self, $webid) = @_;
277 0   0       $webid //= $self->webid;
278              
279             # Lets try plain JSON parsing for fun..
280 0           my $res = $self->get($webid, 'Accept' => 'text/turtle');
281              
282 0 0         return undef unless $res;
283              
284 0           my $util = Web::Solid::Auth::Util->new;
285 0           my $model = $util->parse_turtle($res);
286              
287 0           my $sparql =<<EOF;
288             SELECT ?oidcIssuer {
289             ?subject <http://www.w3.org/ns/solid/terms#oidcIssuer> ?oidcIssuer .
290             }
291             EOF
292              
293 0           my $issuer;
294             $util->sparql($model, $sparql, sub {
295 0     0     my $res = shift;
296 0           $issuer = $res->value('oidcIssuer')->as_string;
297 0           });
298              
299 0           return $issuer;
300             }
301              
302             sub get_client_configuration {
303 0     0 0   my $self = shift;
304              
305 0           my $cache_dir = $self->get_cache_dir;
306 0 0         path($cache_dir)->mkpath unless -d $cache_dir;
307              
308 0           my $openid_conf = $self->get_openid_configuration;
309 0           my $redirect_uri = $self->redirect_uri;
310 0           my $registration_endpoint = $openid_conf->{registration_endpoint};
311              
312 0           my $cache_file = path($cache_dir)->child("client.json")->stringify;
313              
314 0 0         unless (-f $cache_file) {
315 0 0         if ($self->client_id) {
316 0           $self->log->info("using client document at " . $self->client_id);
317              
318 0           my $data = $self->get_json($self->client_id);
319 0           $self->log->debug("generating $cache_file");
320              
321 0           path("$cache_file")->spew(encode_json($data));
322             }
323             else {
324 0           $self->log->info("registering client at $registration_endpoint");
325              
326             # Dynamic register the client. We request the openid and profile
327             # scopes that are default for OpenID. The offline_access is
328             # to be able to request refresh_tokens (not yet implemented).
329             # The only safe response type is 'code' all other options send
330             # sensitive data over the front channel and shouldn't be used.
331 0           my $data = $self->post_json($registration_endpoint, {
332             grant_types => ["authorization_code", "refresh_token"],
333             redirect_uris => [ $redirect_uri ] ,
334             scope => "openid profile offline_access" ,
335             response_types => ["code"]
336             });
337              
338 0 0         return undef unless $data;
339              
340 0           $self->log->infof("received %s", $data);
341              
342 0           $self->log->debug("generating $cache_file");
343              
344 0           path("$cache_file")->spew(encode_json($data));
345             }
346             }
347              
348 0           $self->log->debug("reading $cache_file");
349              
350 0           my $json = path("$cache_file")->slurp;
351              
352 0 0         return undef unless $json;
353              
354 0           return decode_json($json);
355             }
356              
357             sub get_openid_configuration {
358 0     0 0   my ($self) = @_;
359              
360 0           my $issuer = $self->issuer;
361            
362             # remove trailing slash (we will add it)
363 0           $issuer =~ s{\/$}{};
364              
365 0           my $cache_dir = $self->get_cache_dir;
366 0 0         path($cache_dir)->mkpath unless -d $cache_dir;
367              
368 0           my $cache_file = path($cache_dir)->child("openid.json")->stringify;
369              
370 0 0         unless (-f $cache_file) {
371 0           my $url = "$issuer/.well-known/openid-configuration";
372              
373 0           $self->log->info("reading openid configruation from $url");
374              
375             # Get the well known openid
376 0           my $data = $self->get_json($url);
377              
378 0 0         return undef unless $data;
379              
380 0           $self->log->infof("received %s", $data);
381              
382 0           $self->log->debug("generating $cache_file");
383              
384 0           path($cache_file)->spew(encode_json($data));
385             }
386              
387 0           $self->log->debug("reading $cache_file");
388              
389 0           my $json = path($cache_file)->slurp;
390              
391 0 0         return undef unless $json;
392              
393 0           return decode_json($json);
394             }
395              
396             sub get_key_configuration {
397 0     0 0   my ($self) = @_;
398              
399 0           my $cache_dir = $self->get_cache_dir;
400 0 0         path($cache_dir)->mkpath unless -d $cache_dir;
401              
402 0           my $cache_file = path($cache_dir)->child("key.json")->stringify;
403              
404 0 0         unless (-f $cache_file) {
405             # Create an P-256 elliptic curve key we will use in DPoP
406             # headers.
407 0           my $pk = Crypt::PK::ECC->new();
408 0           $pk->generate_key('secp256r1');
409              
410 0           $self->log->debug("generating $cache_file");
411              
412 0           path($cache_file)->spew(encode_json({
413             public => $pk->export_key_jwk('public') ,
414             private => $pk->export_key_jwk('private')
415             }));
416             }
417              
418 0           $self->log->debug("reading $cache_file");
419              
420 0           my $json = path($cache_file)->slurp;
421              
422 0 0         return undef unless $json;
423              
424 0           my $pk = Crypt::PK::ECC->new();
425 0           my $priv = decode_json($json)->{private};
426 0           $pk->import_key(\$priv);
427              
428 0           return $pk;
429             }
430              
431             ## Networking
432              
433             sub get {
434 0     0 0   my ($self, $url, %opts) = @_;
435              
436 0           my $response = $self->agent->get($url, %opts);
437              
438 0 0         unless ($response->is_success) {
439 0           $self->log->errorf("failed to GET($url): %s" , $response);
440 0           return undef;
441             }
442              
443 0           return $response->decoded_content;
444             }
445              
446             sub get_json {
447 0     0 0   my ($self, $url, %opts) = @_;
448 0           return decode_json($self->get($url, %opts));
449             }
450              
451             sub post {
452 0     0 0   my ($self, $url, $data, %opts) = @_;
453              
454 0           my $response = $self->agent->post($url,
455             %opts,
456             Content => $data
457             );
458              
459 0 0         unless ($response->is_success) {
460 0           $self->log->errorf("failed to POST($url): %s",$response);
461 0           return undef;
462             }
463              
464 0           return $response->decoded_content;
465             }
466              
467             sub post_json {
468 0     0 0   my ($self, $url, $data, %opts) = @_;
469              
470 0   0       $opts{'Content-Type'} //= 'application/json';
471              
472 0           my $response = $self->agent->post($url,
473             %opts ,
474             Content => encode_json($data)
475             );
476              
477 0 0         unless ($response->is_success) {
478 0           $self->log->errorf("failed to POST($url): %s",$response);
479 0           return undef;
480             }
481              
482 0           return decode_json($response->decoded_content);
483             }
484              
485             sub options {
486 0     0 0   my ($self, $url) = @_;
487              
488 0           my $response = $self->agent->request(
489             HTTP::Request->new(OPTIONS => $url)
490             );
491              
492 0 0         unless ($response->is_success) {
493 0           $self->log->errorf("failed to OPTIONS($url): %s" , $response);
494 0           return undef;
495             }
496              
497 0           return $response;
498             }
499              
500             sub make_url {
501 0     0 0   my ($self, $url,$params) = @_;
502              
503 0           my @qparam = ();
504              
505 0   0       for my $key (keys %{$params // {} }) {
  0            
506 0           my $value = URI::Escape::uri_escape($params->{$key});
507 0           push @qparam , "$key=$value";
508             }
509              
510 0 0         if (@qparam) {
511 0           $url .= "?" . join("&", @qparam);
512             }
513              
514 0           $url;
515             }
516              
517             # Crypto
518              
519             sub make_random_string {
520 0     0 0   my $self = shift;
521 0           my $str = MIME::Base64::encode_base64url(
522             Data::UUID->new->create() .
523             Data::UUID->new->create() .
524             Data::UUID->new->create()
525             );
526 0           $str;
527             }
528              
529             sub make_token_for {
530 0     0 0   my ($self, $uri, $method) = @_;
531              
532             # With DPoP headers access_tokens can be protected. When requesting
533             # an access_token from a token_endpoint a DPoP headers is included
534             # which contains our public key (inside the signed token header).
535             # Our public key will then be part of the returned access_token.
536             #
537             # When later on you will send the access_token to a resource provider
538             # it can check the signed DPoP header in combination with our public
539             # key in the access_token that you are in posession of the private key
540             # that matches the public key in the access_token.
541             #
542             # In this way, when some evil resource provider steals your access_token
543             # it can't be reused without your private key.
544              
545 0           my $pk = $self->get_key_configuration;
546              
547 0           my $header = {
548             typ => 'dpop+jwt' ,
549             alg => 'ES256' ,
550             jwk => JSON::decode_json($pk->export_key_jwk('public')) ,
551             };
552              
553 0           $self->log->debugf("DPoP(header) %s" , $header);
554              
555 0           my $payload = {
556             # A jti is a random string that protects the token_endpoint server
557             # against replay attacks
558             jti => $self->make_random_string,
559             # Limits the DPoP token only to this method
560             htm => $method ,
561             # Limits the DPop token only to this uri
562             htu => $uri ,
563             # The time this token was issued
564             iat => time ,
565             };
566              
567 0           $self->log->debugf("DPoP(payload) %s" , $payload);
568              
569 0           my $token = Crypt::JWT::encode_jwt(
570             payload => $payload ,
571             key => $pk ,
572             alg => 'ES256' ,
573             extra_headers => $header
574             );
575              
576 0           return $token;
577             }
578              
579             1;
580              
581             __END__
582              
583             =head1 NAME
584              
585             Web::Solid::Auth - A Perl Solid Web Client
586              
587             =head1 SYNOPSIS
588              
589             # On the command line
590              
591             # Set your default webid
592             export SOLID_WEBID=https://timbl.inrupt.net/profile/card#me
593              
594             # Authentication to a pod
595             solid_auth.pl authenticate
596              
597             # Get the http headers for a authenticated request
598             solid_auth.pl headers GET https://timbl.inrupt.net/inbox/
599              
600             # Act like a curl command and fetch authenticated content
601             solid_auth.pl curl -X GET https://timbl.inrupt.net/inbox/
602              
603             # Add some data
604             solid_auth.pl curl -X POST \
605             -H "Content-Type: text/plain" \
606             -d "abc" \
607             https://timbl.inrupt.net/public/
608            
609             # Add a file
610             solid_auth.pl curl -X PUT \
611             -H "Content-Type: application/ld+json" \
612             -d "@myfile.jsonld" \
613             https://timbl.inrupt.net/public/myfile.jsonld
614              
615             # Set a solid base url
616             export SOLID_REMOTE_BASE=https://timbl.inrupt.net
617              
618             # List all resources on some Pod path
619             solid_auth.pl list /public/
620              
621             # Get some data
622             solid_auth.pl get /inbox/
623              
624             # Post some data
625             solid_auth.pl post /inbox/ myfile.jsonld
626              
627             # Put some data
628             solid_auth.pl put /public/myfile.txt myfile.txt
629              
630             # Create a folder
631             solid_auth.pl put /public/mytestfolder/
632              
633             # Delete some data
634             solid_auth.pl delete /public/myfile.txt
635              
636             # Mirror a resource, container or tree
637             solid_auth.pl mirror /public/ ./my_copy
638              
639             # Upload a directory to the pod
640             # Add the -x option to do it for real (only a test without this option)
641             solid_auth.pl -r upload /data/my_copy /public/
642              
643             # Clean all files in a container
644             # Add the -x option to do it for real (only a test without this option)
645             solid_auth.pl --keep clean /demo/
646              
647             # Clean a complete container
648             # Add the -x option to do it for real (only a test without this option)
649             solid_auth.pl -r clean /demo/
650              
651             # In a perl program
652             use Web::Solid::Auth;
653             use Web::Solid::Auth::Listener;
654              
655             # Create a new authenticator for a pod
656             my $auth = Web::Solid::Auth->new(webid => $webid);
657              
658             # Or tune a listerner
659             my $auth = Web::Solid::Auth->new(
660             webid => $webid ,
661             listener => Web::Solid::Auth::Listener->new(
662             scheme => 'https'
663             host => 'my.server.org'
664             port => '443' ,
665             path => '/mycallback'
666             )
667             );
668              
669             # Or, in case you have your own callback server
670             my $auth = Web::Solid::Auth->new(
671             webid => $webid,
672             redirect_uri => 'https://my.server.org/mycallback'
673             );
674              
675             # Generate a url for the user to authenticate
676             my $auth_url = $auth->make_authorization_request;
677              
678             # Listen for the oauth server to return tokens
679             # the built-in listener for feedback from the openid provider
680             # Check the code of Web::Solid::Auth::Listener how to
681             # do this inside your own Plack application
682             $auth->listen;
683              
684             ####
685              
686             # If you already have access_tokens from previous step
687             if ($auth->has_access_token) {
688             # Fetch the Authentication and DPoP HTTP headers for a
689             # request to an authorized resource
690             my $headers = $auth->make_authentication_headers($resource_url,$http_method);
691              
692             #..do you curl..lwp::agent..or what ever with the headers
693             }
694              
695             =head1 INSTALLATION
696              
697             See the L<https://metacpan.org/dist/Web-Solid-Auth/source/INSTALL> file in the
698             distribution.
699              
700             =head1 DESCRIPTION
701              
702             This is a Solid-OIDC implementation of a connection class for the Solid
703             server. Use the C<bin/solid_auth.pl> command as a command line implementation.
704             Check out the C<example> directory for a demo web application.
705              
706             =head1 CONFIGURATION
707              
708             =over
709              
710             =item webid
711              
712             The Solid Webid to authenticate.
713              
714             =item cache
715              
716             The location of the cache directory with connection parameters.
717              
718             =back
719              
720             =head1 METHODS
721              
722             =over
723              
724             =item has_access_token()
725              
726             Returns a true value when a cache contains an access token for the C<webid>.
727              
728             =item make_clean()
729              
730             Clear the cache directory.
731              
732             =item make_authorization_request()
733              
734             Return an authorization URL that the use should open to authenticate this
735             application.
736              
737             =item make_access_token($code)
738              
739             When on the redirect url you get a C<code> from the authentication server you
740             can use this method to get an access_token for the code.
741              
742             =item listen()
743              
744             Create a small built-in web server to listen for token responses from the
745             authentication server.
746              
747             =item get_access_token()
748              
749             Return the cached access_token.
750              
751             =back
752              
753             =head1 SEE ALSO
754              
755             L<solid_auth.pl>
756              
757             =head1 INSPIRATION
758              
759             This was very much inspired by the Python solid-flask code by
760             Rai L<http://agentydragon.com> at L<https://gitlab.com/agentydragon/solid-flask>,
761             and Jeff Zucker's <https://github.com/jeff-zucker> Solid-Shell at L<https://www.npmjs.com/package/solid-shell>.
762              
763             =head1 COPYRIGHT AND LICENSE
764              
765             This software is copyright (c) 2021 by Patrick Hochstenbach.
766              
767             This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.
768              
769             =encoding utf8
770              
771             =cut