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   66607 use strict;
  1         2  
  1         28  
4 1     1   5 use warnings;
  1         2  
  1         51  
5            
6             our $VERSION = '0.05';
7            
8 1     1   439 use parent 'LWP::UserAgent';
  1         311  
  1         5  
9            
10 1     1   52876 use JSON;
  1         11058  
  1         5  
11 1     1   821 use Storable;
  1         3019  
  1         58  
12 1     1   537 use Data::UUID;
  1         728  
  1         62  
13 1     1   7 use File::Spec;
  1         3  
  1         30  
14 1     1   5 use Storable;
  1         2  
  1         36  
15 1     1   22 use Carp;
  1         2  
  1         48  
16 1     1   9 use URI;
  1         2  
  1         32  
17 1     1   500 use HTTP::Request::Common;
  1         2369  
  1         90  
18 1     1   909 use Net::EmptyPort qw(listen_socket empty_port check_port);
  1         42441  
  1         1656  
19            
20             sub new($%) {
21            
22 1     1 1 151 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         4 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       5 unless (exists $internals{sid}) {
38 1         413 my $guid=Data::UUID->new;
39 1         286 $internals{sid}=$guid->create_str();
40             }
41            
42 1         7 my $sid=$internals{sid};
43            
44 1 50       5 $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         3 $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     4 $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     4 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         3053 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   13 use base 'HTTP::Server::Simple::CGI';
  1         10  
  1         523  
313 1     1   10395 use HTTP::Server::Simple::CGI;
  1         2  
  1         45  
314 1     1   6 use IO::Socket qw(AF_INET AF_UNIX SOCK_STREAM SHUT_WR);
  1         3  
  1         7  
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.05
414            
415             =head1 SYNOPSIS
416            
417             use LWP::UserAgent::msgraph;
418            
419             #The XXXX, YYYY and ZZZZ are from your Azure App Registration
420            
421             #Application Permission version
422             $ua = LWP::UserAgent::msgraph->new(
423             appid => 'XXXX',
424             secret => 'YYYY',
425             tenant => 'ZZZZ',
426             grant_type => 'client_credentials');
427            
428             #Delegated authentication version
429             $ua = LWP::UserAgent::msgraph->new(
430             appid => 'XXXX',
431             secret => 'YYYY',
432             tenant => 'ZZZZ',
433             grant_type=> 'authorization_code',
434             scope => 'openid user.read');
435             $ua->auth($code_obtained_from_challenge);
436            
437             $joe = $ua->request(GET => '/users/jdoe@some.com');
438             $dn = $joe->{displayName};
439            
440             =head1 DESCRIPTION
441            
442             This module allows the interaction between Perl and the MS Graph API service.
443             Therefore, a MS Graph application can be built using Perl. The application must
444             be correctly registered within Azure with the proper persmissions.
445            
446             This module has the glue for the needed authentication scheme and the JSON
447             serialization so a conversation can be established with MS Graph. This is just
448             middleware. No higher level object abstraction is provided for the MS Graph
449             object data.
450            
451             =head1 CONSTRUCTOR
452            
453             my $ua=LWP::UserAgent->new(%options);
454            
455             This method constructs a new L object.
456             key/value pairs must be supplied in order to setup the object
457             properly. Missing mandatory options will result in error
458            
459             KEY MEANING
460             ------- -----------------------------------
461             appid Application (client) ID
462             secret shared secret needed for handshake
463             tenant Tenant id
464             grant_type Authorizations scheme (client_credentials,authorization_code)
465             console Indicates whether interaction with a user is possible
466             redirect_uri Redirect URI for delegated auth challenge
467             local_port tcp port for mini http server. Defaults to 8081
468            
469             =head1 auth
470            
471             my $token = $ua->auth; #For app credentiales
472             my $token = $ua->auth($challenge); #For delegated authentication
473            
474             This method performs the authentication handshake sequence with the MS
475             Graph platform. The optional parameter is the authorization code obtained
476             from a challenge with the impersonated user. If this is an application
477             non-delegated client, then the $challenge is not needed.
478            
479             If used in a web application, you should have redirected the user to the authendpoint() location
480             and then capture the resulting code listening for the redirect_uri.
481            
482             A special tweak is supplied for console applications with delegated authentication. In that case,
483             if the code is missing, an http localhost miniserver is launched so the
484             user can trigger the challenge himself. This behavior is activated via the console constructor option.
485             The http miniserver is destroyed as soon as the authorization code arrives.
486             In this case, the redirect_uri is automatically set. The miniserver listens by default on http://localhost:8081.
487             Please note that MS Graph allows
488             the use of localhost in the redirect_uri and in that case SSL is not enforced. But still the
489             localhost URL must be registered in Azure.
490            
491             =head1 request
492            
493             my $object=$ua->request(GET => '/me');
494             $ua->request(PATCH => '/me', {officeLocation => $mynewoffice});
495            
496             The request method makes a call to a MS Graph endpoint url and returns the
497             corresponding response object. An optional perl structure might be
498             supplied as the payload (body) for the request.
499            
500             The MS Graph has a rich set of API calls for different operations. Check the
501             EXAMPLES section for more tips.
502            
503             =head1 code
504            
505             print "It worked" if ($ua->code == 201);
506            
507             A code() method is supplied as a convenient way of getting the last HTTP response
508             code.
509            
510             =head1 next
511            
512             $more=$ua->next();
513            
514             The next() method will request additional response content after a previous
515             request if a pagination result set happens.
516            
517             =head1 authendpoint
518            
519             $location=$ua->authendpoint()
520            
521             Returns the authentication endpoint as an url string, full with the query part. In a delegated
522             authentication mode, you should point the user to this url via a browser in order to get the proper
523             authorization. This is on offline method, the resulting uri is computed from the constructor options
524            
525             =head1 tokenendpoint
526            
527             $location=$ua->tokenendpoint()
528            
529             Returns the oauth 2.0 token endpoint as an url string. This url is used internally to get
530             the authentication token.
531            
532             =head1 Changes from the default LWP::UserAgent behavior
533            
534             This class inherits from L, but some changes apply. If you are used to
535             LWP::UserAgent standart tweaks and shortcuts, you should read this.
536            
537             The request() method accepts a perl structure which will be sent
538             as a JSON body to the MS Graph endoint. Instead of an L
539             object, request() will return whatever object is returned by the
540             MS Graph method, as a perl structure. The L module is used as
541             a serialization engine.
542            
543             request() will use the right Authorization header based on the initial handshake.
544             The get(), post(), patch(), delete(), put(), delete() methods are setup so
545             they call the LWP::UserAgent::msgraph version of request(). That is, they would
546             return a perl structure according to the MS Graph method.
547             In particular, post() and patch() accepts a perl structure
548             as the body. All the binding with the L module has been broken.
549            
550             The simple_request() method is kept unchanged, but will use the
551             right Bearer token authentication. So, if you need more control over the request, you can use
552             this method. You must add the JSON serialization, though.
553            
554            
555            
556             =cut