File Coverage

blib/lib/LWP/UserAgent/msgraph.pm
Criterion Covered Total %
statement 71 197 36.0
branch 11 52 21.1
condition 3 17 17.6
subroutine 16 36 44.4
pod 8 16 50.0
total 109 318 34.2


line stmt bran cond sub pod time code
1             package LWP::UserAgent::msgraph;
2            
3 1     1   67730 use strict;
  1         2  
  1         29  
4 1     1   14 use warnings;
  1         2  
  1         37  
5            
6             our $VERSION = '0.04';
7            
8 1     1   483 use parent 'LWP::UserAgent';
  1         315  
  1         5  
9            
10 1     1   54000 use JSON;
  1         10252  
  1         5  
11 1     1   785 use Storable;
  1         2829  
  1         61  
12 1     1   467 use Data::UUID;
  1         614  
  1         72  
13 1     1   7 use File::Spec;
  1         2  
  1         24  
14 1     1   5 use Storable;
  1         2  
  1         36  
15 1     1   11 use Carp;
  1         2  
  1         52  
16 1     1   9 use URI;
  1         2  
  1         24  
17 1     1   479 use HTTP::Request::Common;
  1         2268  
  1         69  
18 1     1   494 use Net::EmptyPort qw(listen_socket empty_port check_port);
  1         40476  
  1         1715  
19            
20             sub new($%) {
21            
22 1     1 1 147 my %internals;
23            
24 1         3 my $class=shift();
25            
26 1         5 my %args=@_;
27            
28             #This are our lwp-extended options
29 1         5 for (qw(appid secret grant_type scope persistent sid base store return_url tenant local_port)) {
30 11 100       28 if (exists $args{$_}) {
31 3         6 $internals{$_}= $args{$_};
32 3         6 delete $args{$_};
33             }
34             }
35            
36             #Some defaults
37 1 50       4 unless (exists $internals{sid}) {
38 1         470 my $guid=Data::UUID->new;
39 1         318 $internals{sid}=$guid->create_str();
40             }
41            
42 1         8 my $sid=$internals{sid};
43            
44 1 50       6 $internals{base}='https://graph.microsoft.com/v1.0' unless(exists $internals{base});
45 1         4 $internals{base} =~ s/\/$//;
46            
47 1 50       5 $internals{console}=0 unless (exists $internals{console});
48            
49 1         2 $internals{expires}=0;
50 1 50       5 $internals{local_port}=8081 unless ($internals{local_port});
51            
52             #complain about missing options
53 1         3 for (qw(appid grant_type tenant)) {
54 3 50       9 croak "Missing mandatory option $_" unless (exists $internals{$_});
55             }
56            
57             #Now the persistent thing
58 1 50 33     5 $internals{persistent}=1 if (exists $internals{store} && ! exists $internals{persistent});
59 1 50       5 $internals{persistent}=0 unless (exists $internals{persistent});
60            
61 1 50 33     3 if ($internals{persistent} && ! exists $internals{store}) {
62 0         0 my $tmpdir = File::Spec->tmpdir();
63 0         0 $internals{store}="$tmpdir/$sid.tmp";
64             }
65            
66 1 50 33     4 if ($internals{persistent} && -r $internals{store}) {
67 0         0 my $stored=retrieve($internals{store});
68 0 0       0 croak 'Mismatch persistent session' unless ($stored->{sid} eq $sid);
69 0         0 for (keys %$stored) {
70 0         0 $internals{$_}=$stored->{$_};
71             }
72             }
73            
74 1         12 my $self=$class->SUPER::new(%args);
75 1         3014 for (keys %internals) {
76 9         19 $self->{$_} = $internals{$_};
77             }
78            
79 1         5 return $self;
80            
81             }
82            
83             sub writestore($) {
84            
85 0     0 0   my $self=shift();
86            
87 0 0         croak 'Wrong writestore call on non-persistant client' unless ($self->{persistent});
88            
89 0           my $data={};
90            
91             #This is a subset of the runtime data. It's important that the secret is out
92 0           for (qw(access_token expires expires_in refresh_token token_type scope appid sid redirect_uri console)) {
93 0           $data->{$_}=$self->{$_};
94             }
95 0           return store $data, $self->{store};
96             }
97            
98             sub request {
99            
100 0     0 1   my ($self,$method, $url, $payload)=@_;
101            
102 0           $url =~ s/^\///;
103            
104 0           my $abs_uri=URI->new_abs($url, $self->{base}.'/');
105            
106 0           my $req=HTTP::Request->new($method,"$abs_uri");
107 0           $req->header('Content-Type' => 'application/json');
108 0           $req->header('Accept' => 'application/json');
109 0 0         $req->content(to_json($payload)) if ($payload);
110            
111 0           my $res=LWP::UserAgent::request($self,$req);
112            
113             #Response code is a keeper
114 0           $self->{code}=$res->code;
115            
116 0 0         if ($res->is_success) {
117 0           my $data=from_json($res->decoded_content);
118 0 0         if (exists $data->{'@odata.nextLink'}) {
119 0           $self->{nextLink}=$data->{'@odata.nextLink'};
120             } else {
121 0           $self->{nextLink}=0;
122             }
123 0           return $data;
124             } else {
125 0           croak $res->decoded_content
126             }
127             }
128            
129             sub code($) {
130            
131 0     0 0   my $self=shift();
132 0           return $self->{code};
133             }
134            
135             sub next($) {
136            
137 0     0 0   my $self=shift();
138            
139 0 0         if ($self->{nextLink}) {
140 0           return $self->request('GET' => $self->{nextLink});
141             } else {
142 0           return 0;
143             }
144             }
145            
146             sub authendpoint($) {
147            
148 0     0 0   my $self=shift();
149            
150             #This is an ugly url. Must be used as a GET or a redirect location, so can't be done as POST
151 0           my $url=URI->new("https://login.microsoftonline.com/".$self->{tenant}."/oauth2/v2.0/authorize");
152            
153             #query_param_append comes handy, but was introduced in URI 5.16
154 0           $url->query_param_append('client_id' => $self->{appid});
155 0           $url->query_param_append('response_type' => 'code');
156 0           $url->query_param_append('redirect_uri' => $self->{redirect_uri});
157 0           $url->query_param_append('response_mode' => 'query');
158 0           $url->query_param_append('scope' => $self->{scope});
159 0           $url->query_param_append('state' => $self->{sid});
160 0           return "$url";
161             }
162            
163             sub tokenendpoint($) {
164            
165 0     0 0   my $self=shift();
166 0           return "https://login.microsoftonline.com/".$self->{tenant}."/oauth2/v2.0/token";
167             }
168            
169             sub sid($) {
170 0     0 0   my $self=shift();
171 0           return $self->{sid};
172             }
173            
174             sub consolecode($) {
175            
176 0     0 0   my $self=shift();
177            
178 0           my $port=$self->{local_port};
179 0           my $web=LWP::UserAgent::msgraph::srvauth->new($port);
180            
181             #Even if it's local, this redirect_uri must be Azure-registered
182 0           $self->{redirect_uri}="http://localhost:$port/auth";
183            
184             #In order to setup a well-behaved http mini-server, we launch the server as a separate background
185             #process using the HTTP::Server::Simple module.
186             #Since this will be a separate process, and we need the authorization code value, we setup a
187             #private listening socket so the child process can upload the code to us
188 0           my $socket=listen_socket();
189 0           $web->setcaller($self, $socket->sockport);
190 0           my $pid=$web->background();
191            
192 0           my $client=$socket->accept();
193 0           my $data="";
194 0           $client->recv($data,1024);
195            
196 0           my ($id,$code)=split /\s/, $data;
197            
198             #Our session id is sent as the optional 'state' parameter
199             #This value comes back to us along with the authorization code
200             #Here, we honour the state value validation. If the state value
201             #is not a match, the authorization code is discarded
202 0 0 0       if ($id && $id eq $self->sid) {
203 0           print "Authorization code received. You can close the browser now\n";
204 0           return $code;
205             } else {
206 0           return 0;
207             }
208             }
209            
210             sub auth {
211            
212 0     0 0   my $self=shift();
213            
214 0           my $post;
215            
216             #Here comes the authentication handshake with the MS Graph platform
217             #This is all spoken in application/x-www-form-urlencoded, so we use
218             #the standard simple_request and HTTP::Request approach
219            
220             #Client-credentials for user-less anonymous connection
221 0 0         if ($self->{grant_type} eq 'client_credentials') {
    0          
222            
223             $post=HTTP::Request::Common::POST($self->tokenendpoint(),
224             [client_id => $self->{appid},
225             scope => 'https://graph.microsoft.com/.default',
226             client_secret=> $self->{secret},
227             grant_type => $self->{grant_type}
228 0           ]);
229            
230             #Delegated authorization for user-oriented interaction
231             } elsif ($self->{grant_type} eq 'authorization_code') {
232            
233 0           my $code=shift();
234 0 0 0       $code=$self->consolecode() unless ($code || ! $self->{console});
235 0 0         croak 'Missing or invalid authorization code' unless ($code);
236            
237             $post=HTTP::Request::Common::POST($self->tokenendpoint(),
238             [client_id => $self->{appid},
239             scope => $self->{scope},
240             code => $code,
241             redirect_uri => $self->{redirect_uri},
242             client_secret=> $self->{secret},
243             grant_type => $self->{grant_type}
244 0           ]);
245            
246             } else {
247 0           croak 'Missing or unsupported grant_type';
248             }
249            
250 0 0         croak 'Authentication scheme error' unless ($post);
251            
252 0           my $r=$self->simple_request($post);
253 0 0         unless ($r->is_success) {
254 0           croak "Authentication failure ".$r->decoded_content;
255             }
256            
257 0           my $data=from_json($r->decoded_content);
258 0           for (keys %$data) {
259 0           $self->{$_}=$data->{$_};
260             }
261            
262 0           $self->{expires}=(time + $data->{expires_in});
263 0 0         $self->writestore() if ($self->{presistent});
264 0           $self->default_header('Authorization' => "Bearer ".$self->{access_token});
265            
266 0           return $data->{access_token};
267             }
268            
269             sub get {
270            
271 0     0 1   my ($self,@params)=@_;
272            
273 0           return $self->request('GET',@params);
274             }
275            
276             sub post {
277 0     0 1   my ($self,@params)=@_;
278            
279 0           return $self->request('POST',@params);
280            
281             }
282            
283             sub head {
284 0     0 1   my ($self,@params)=@_;
285            
286 0           return $self->request('HEAD',@params);
287            
288             }
289            
290             sub patch {
291 0     0 1   my ($self,@params)=@_;
292            
293 0           return $self->request('PATCH',@params);
294            
295             }
296            
297             sub put {
298 0     0 1   my ($self,@params)=@_;
299            
300 0           return $self->request('PUT',@params);
301            
302             }
303            
304             sub delete {
305 0     0 1   my ($self,@params)=@_;
306            
307 0           return $self->request('DELETE',@params);
308            
309             }
310            
311             package LWP::UserAgent::msgraph::srvauth;
312 1     1   9 use base 'HTTP::Server::Simple::CGI';
  1         2  
  1         515  
313 1     1   9883 use HTTP::Server::Simple::CGI;
  1         2  
  1         44  
314 1     1   6 use IO::Socket qw(AF_INET AF_UNIX SOCK_STREAM SHUT_WR);
  1         2  
  1         6  
315            
316             sub valid_http_method($$) {
317            
318 0     0     my ($self,$method)=@_;
319 0           return ($method eq 'GET');
320             }
321             sub setcaller($$$) {
322            
323 0     0     my $self=shift();
324 0           my $ms=shift();
325 0           my $port=shift();
326            
327 0           $self->{'code_uri'}=$ms->authendpoint();
328 0           $self->{'callerport'}=$port;
329 0           return 1;
330             }
331            
332             sub sendcode($$$) {
333            
334 0     0     my ($self,$code,$state)=@_;
335            
336             my $client = IO::Socket->new(
337             Domain => AF_INET,
338             Type => SOCK_STREAM,
339             proto => 'tcp',
340             PeerPort => $self->{callerport},
341 0   0       PeerHost => '127.0.0.1',
342             ) || die "Can't open socket: $IO::Socket::errstr";
343            
344 0           $client->send($state.' '.$code);
345 0           $client->shutdown(SHUT_WR);
346 0           $client->close();
347             }
348            
349             #Here we setup a minimal web server response behavior
350             #The only verbs allowed are:
351             # GET /start ==> does a 302 redirect to the MS authorization platform
352             # GET /auth ==> receives the authorization code in the query string
353             #
354             # This two methods performs an MS challenge to the end-user
355             #
356             # Note that depending on your particular browser state, there could be
357             # a valid MS tenant session already logged in with this app previously
358             # authorized. In that case, the user doesn't get the login challenge
359             # and the only thing the browser performs is a series of redirects
360             # In that case, the authorization code get to us in a blink-you-missed-it
361             # fashion
362             sub handle_request {
363 0     0     my $self = shift;
364 0           my $cgi = shift;
365            
366 0           my $path = $cgi->request_uri();
367            
368 0 0         if ($path =~ "^/auth" ) {
    0          
369 0           print "HTTP/1.0 200 OK\r\n";
370 0           my $msg="Authentication ok. You can close this window now.\n";
371 0           print $cgi->header(-type=>'text/plain', -Content_length => length($msg));
372 0           my $code=$cgi->param('code');
373 0           my $state=$cgi->param('state');
374 0           $self->sendcode($code,$state);
375 0           print $msg;
376            
377 0           exit 0;
378             } elsif ($path =~ "^/start" ) {
379 0           print "HTTP/1.0 302 Redirected\r\n";
380 0           print $cgi->redirect($self->{'code_uri'});
381             }
382             else {
383 0           print "HTTP/1.0 404 Not found\r\n";
384 0           print $cgi->header,
385             $cgi->start_html('Not found'),
386             $cgi->h1('Not found'),
387             $cgi->end_html;
388             }
389             }
390            
391             sub print_banner($) {
392 0     0     my $self=shift();
393            
394 0           my $url="http://localhost:".$self->port()."/start";
395 0           print "Authentication required.\nOpen your browser at $url\n";
396            
397             }
398            
399            
400            
401             1;
402            
403             =pod
404            
405             =encoding UTF-8
406            
407             =head1 NAME
408            
409             LWP::UserAgent::msgraph
410            
411             =head1 VERSION
412            
413             version 0.01
414            
415             =head1 SYNOPSIS
416            
417             use LWP::UserAgent::msgraph;
418            
419             #The XXXX, YYYY and ZZZZ are from your Azure App Registration
420             #Application Permission version
421             $ua = LWP::UserAgent::msgraph->new(
422             appid => 'XXXX',
423             secret => 'YYYY',
424             tenant => 'ZZZZ',
425             grant_type => 'client_credentials');
426             $joe=$ua->request(GET => '/users/jdoe@some.com');
427             $dn=$joe->{displayName};
428            
429             =head1 DESCRIPTION
430            
431             This module allows the interaction between Perl and the MS Graph API service.
432             Therefore, a MS Graph application can be built using Perl. The application must
433             be correctly registered within Azure with the proper persmissions.
434            
435             This module has the glue for the needed authentication scheme and the JSON
436             serialization so a conversation can be established with MS Graph. This is only
437             middleware. No higher level object abstraction is provided for the MS Graph
438             object data.
439            
440             =head1 CONSTRUCTOR
441            
442             my $ua=LWP::UserAgent->new(%options);
443            
444             This method constructs a new L object.
445             key/value pairs must be supplied in order to setup the object
446             properly. Missing mandatory options will result in error
447            
448             KEY MEANING
449             ------- -----------------------------------
450             appid Application (client) ID
451             secret shared secret needed for handshake
452             tenant Tenant id
453             grant_type Authorizations scheme (client_credentials,authorization_code)
454             console Indicates whether interaction with a user is possible
455             redirect_uri Redirect URI for delegated auth challenge
456             local_port tcp port for mini http server. Defaults to 8081
457            
458             =head1 auth
459            
460             my $token = $ua->auth; #For app credentiales
461             my $token = $ua->auth($challenge); #For delegated authentication
462            
463             This method performs the authentication handshake sequence with the MS
464             Graph platform. The optional parameter is the authorization code obtained
465             from a challenge with the impersonated user. If this is an application only
466             non-delegated client, then the $challenge is not needed.
467            
468             If used in a web application, you should have redirected the user to the L location
469             and then capture the resulting code listening for the redirect_uri.
470            
471             A special tweak is supplied for console applications with delegated authentication. In that case,
472             if the code is missing, an http localhost miniserver is launched so the
473             user can trigger the challenge himself. This behavior is activated via the console constructor option.
474             The http miniserver is destroyed as soon as the authorization code arrives.
475             In this case, the redirect_uri is automatically set. The miniserver listens by default on http://localhost:8081.
476             Please note that MS Graph allows
477             the use of localhost in the redirect_uri and in that case SSL is not enforced. But still the
478             localhost URL must be registered in Azure.
479            
480             =head1 request
481            
482             my $object=$ua->request(GET => '/me');
483             $ua->request(PATCH => '/me', {officeLocation => $mynewoffice});
484            
485             The request method makes a call to a MS Graph endpoint url and returns the
486             corresponding response object. An optional perl structure might be
487             supplied as the payload (body) for the request.
488            
489             The MS Graph has a rich set of API calls for different operations. Check the
490             L section for more tips.
491            
492             =head1 code
493            
494             print "It worked" if ($ua->code == 201);
495            
496             A code() method is supplied as a convenient way of getting the last HTTP response
497             code.
498            
499             =head1 next
500            
501             $more=$ua->next();
502            
503             The next() method will request additional response content after a previous
504             request if a pagination result set happens.
505            
506             =head1 authendpoint
507            
508             $location=$ua->authendpoint()
509            
510             Returns the authentication endpoint as an url string, full with the query part. In a delegated
511             authentication mode, you should point the user to this url via a browser in order to get the proper
512             authorization. This is on offline method, the resulting uri is computed from the constructor options
513            
514             =head1 tokenendpoint
515            
516             $location=$ua->tokenendpoint()
517            
518             Returns the oauth 2.0 token endpoint as an url string. This url is used internally to get
519             the authentication token.
520            
521             =head1 Changes from the default LWP::UserAgent behavior
522            
523             This class inherits from L, but some changes apply. If you are used to
524             LWP::UserAgent standart tweaks and shortcuts, you should read this.
525            
526             The L now accepts a perl structure which will be sent
527             as a JSON body to the MS Graph endoint. Instead of an L
528             object, request() will return whatever object is returned by the
529             MS Graph method, as a perl structure. The module is used as
530             a serialization engine.
531            
532             request() will use the right Authorization header based on the initial handshake.
533             The get(), post(), patch(), delete(), put(), delete() methods are setup so
534             they call the LWP::UserAgent::msgraph version of request(). That is, they would
535             return a perl structure according to the MS Graph method.
536             In particular, post() and patch() accepts a perl structure
537             as the body. All the binding with the L module has been broken.
538            
539             The simple_request() method is kept unchanged, but will use the
540             right Bearer token authentication. So, if you need more control over the request, you can use
541             this method. You must add the JSON serialization, though.
542            
543            
544            
545             =cut